// ================================================================
// FILE 1 of 11 · DDPApp.swift
// The entry point. Wires the model to the root view.
// ================================================================
//
// DDPApp.swift
// Morning Prayer
//
import SwiftUI
@main
struct DailyDeploymentPrayerApp: App {
@StateObject private var model = AppModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
Group {
if model.onboardingComplete {
HomeView()
} else {
OnboardingView()
}
}
.environmentObject(model)
// The warm sunrise design is a light design; pinning the scheme
// keeps system dark mode from turning fields and lists black.
.preferredColorScheme(.light)
.animation(.easeInOut(duration: 0.4), value: model.onboardingComplete)
.onChange(of: scenePhase) { _, phase in
if phase == .active {
model.reconcileOnForeground()
}
}
}
}
}
// ================================================================
// FILE 2 of 11 · AppModel.swift
// The brain: all app state, the streak, and the Screen Time locking and unlocking.
// ================================================================
//
// AppModel.swift
// Morning Prayer
//
// Single observable object that owns all app state. No backend, no
// third-party code — everything persists in App Group UserDefaults so
// the Screen Time extensions can read it too.
//
import Foundation
import SwiftUI
import FamilyControls
import ManagedSettings
import DeviceActivity
@MainActor
final class AppModel: ObservableObject {
// MARK: - Published state
@Published var authorizationStatus: AuthorizationStatus =
AuthorizationCenter.shared.authorizationStatus
@Published var selection: FamilyActivitySelection = SharedStore.loadSelection() {
didSet {
SharedStore.saveSelection(selection)
// Picking specific apps switches off "lock all apps" mode.
if hasSelection && lockAllApps { lockAllApps = false }
if lockEnabled && !isUnlocked { ShieldController.applyShields() }
// Keep the usage guard watching the current app list.
if lockEnabled { startDailyGuard() }
}
}
/// "Lock all apps" — shield every category-shieldable app instead of a
/// hand-picked list. Encouraged during onboarding.
@Published var lockAllApps: Bool =
SharedStore.defaults.bool(forKey: SharedStore.lockAllKey) {
didSet {
SharedStore.defaults.set(lockAllApps, forKey: SharedStore.lockAllKey)
if lockEnabled && !isUnlocked { ShieldController.applyShields() }
}
}
@Published var lockEnabled: Bool =
SharedStore.defaults.bool(forKey: SharedStore.lockEnabledKey)
@Published var unlockUntil: Date? =
SharedStore.defaults.object(forKey: SharedStore.unlockUntilKey) as? Date
@Published var lovedOnes: [String] = SharedStore.loadLovedOnes() {
didSet { SharedStore.saveLovedOnes(lovedOnes) }
}
@Published var streak: Int =
SharedStore.defaults.integer(forKey: SharedStore.streakKey)
@Published var lastPrayedDay: Date? =
SharedStore.defaults.object(forKey: SharedStore.lastPrayedDayKey) as? Date
@Published var showPrayerFlow = false
@Published var onboardingComplete: Bool =
SharedStore.defaults.bool(forKey: SharedStore.onboardingCompleteKey)
init() {
// One-time migration: earlier builds applied shields through a custom
// named store. Clear it so only the default store is ever in play.
ManagedSettingsStore(named: .ddprayer).clearAllSettings()
// Grandfather existing users: anyone who already granted Screen Time
// and added names shouldn't be walked through onboarding again.
if !onboardingComplete, isAuthorized, !lovedOnes.isEmpty {
completeOnboarding()
}
// Make sure the daily checkpoints are registered whenever the
// lock is on (covers users updating from builds that lacked them).
if lockEnabled { startDailyGuard() }
}
/// Staggered repeating daily checkpoints (2:00, 2:15, and 3:00 AM) —
/// independent of the one-shot window event, and deliberately
/// morning-only so nothing can ever touch a user's apps later in the day.
private func startDailyGuard() {
let center = DeviceActivityCenter()
center.stopMonitoring(DeviceActivityName.morningGuards + [.usageGuard])
let checkpoints: [(DeviceActivityName, Int, Int)] = [
(.dailyGuard, 2, 0),
(.guard0215, 2, 15),
(.guard0300, 3, 0),
]
for (name, hour, minute) in checkpoints {
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: hour, minute: minute),
intervalEnd: DateComponents(hour: hour + 1, minute: minute),
repeats: true
)
try? center.startMonitoring(name, during: schedule)
}
// Usage-triggered rescue (2 AM–noon): two minutes of real usage in the
// watched apps launches our monitor even if every timed checkpoint was
// missed. Needs concrete tokens, so pure "lock all apps" users (who
// never picked specifics) rely on the nightly checkpoints alone.
if hasSelection {
let event = DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: 2)
)
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 2, minute: 0),
intervalEnd: DateComponents(hour: 11, minute: 59),
repeats: true
)
try? center.startMonitoring(.usageGuard, during: schedule,
events: [.morningUsage: event])
}
}
func completeOnboarding() {
onboardingComplete = true
SharedStore.defaults.set(true, forKey: SharedStore.onboardingCompleteKey)
}
// MARK: - Derived
var isAuthorized: Bool { authorizationStatus == .approved }
var isUnlocked: Bool { (unlockUntil ?? .distantPast) > Date() }
var prayedToday: Bool {
lastPrayedDay.map { Calendar.current.isDateInToday($0) } ?? false
}
/// The streak shown in the UI: the stored count while it's alive
/// (prayed today or yesterday), otherwise it has lapsed back to 0.
var displayStreak: Int {
guard let last = lastPrayedDay else { return 0 }
let calendar = Calendar.current
if calendar.isDateInToday(last) || calendar.isDateInYesterday(last) {
return streak
}
return 0
}
var hasSelection: Bool {
!selection.applicationTokens.isEmpty
|| !selection.categoryTokens.isEmpty
|| !selection.webDomainTokens.isEmpty
}
var selectionSummary: String {
if lockAllApps { return "All apps" }
let apps = selection.applicationTokens.count
let categories = selection.categoryTokens.count
let sites = selection.webDomainTokens.count
if apps + categories + sites == 0 { return "None yet" }
var parts: [String] = []
if apps > 0 { parts.append("\(apps) app\(apps == 1 ? "" : "s")") }
if categories > 0 { parts.append("\(categories) categor\(categories == 1 ? "y" : "ies")") }
if sites > 0 { parts.append("\(sites) website\(sites == 1 ? "" : "s")") }
return parts.joined(separator: ", ")
}
// MARK: - Screen Time authorization
func requestAuthorization() async {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
} catch {
// User declined, or Screen Time is unavailable (e.g. Simulator).
}
authorizationStatus = AuthorizationCenter.shared.authorizationStatus
}
// MARK: - Lock control
func setLock(enabled: Bool) {
lockEnabled = enabled
SharedStore.defaults.set(enabled, forKey: SharedStore.lockEnabledKey)
if enabled {
startDailyGuard()
if let end = SharedStore.activeUnlockWindowEnd() {
// They already prayed today — honor the open window.
SharedStore.defaults.set(end, forKey: SharedStore.unlockUntilKey)
unlockUntil = end
ShieldController.clearShields()
} else {
unlockUntil = nil
ShieldController.applyShields()
}
} else {
// Emergency/manual pause — this is a morning ritual, not a cage.
DeviceActivityCenter().stopMonitoring(
[.unlockWindow, .usageGuard] + DeviceActivityName.morningGuards)
ShieldController.clearShields()
SharedStore.defaults.removeObject(forKey: SharedStore.unlockUntilKey)
unlockUntil = nil
}
}
/// "I'm done early" — end the access window now. Clearing the prayed-at
/// marker matters: it's what tells the self-heal logic this re-lock is
/// intentional, not a system glitch to repair.
func relockNow() {
guard lockEnabled else { return }
DeviceActivityCenter().stopMonitoring([.unlockWindow])
SharedStore.defaults.removeObject(forKey: SharedStore.lastPrayedAtKey)
ShieldController.applyShields()
unlockUntil = nil
}
// MARK: - Prayer completion
func completePrayer() {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
if let last = lastPrayedDay, calendar.isDateInToday(last) {
// Already counted today — praying again doesn't inflate the streak.
} else if let last = lastPrayedDay, calendar.isDateInYesterday(last) {
streak += 1
} else {
streak = 1
}
lastPrayedDay = today
SharedStore.defaults.set(streak, forKey: SharedStore.streakKey)
SharedStore.defaults.set(today, forKey: SharedStore.lastPrayedDayKey)
SharedStore.defaults.set(Date(), forKey: SharedStore.lastPrayedAtKey)
SharedStore.defaults.set(false, forKey: SharedStore.pendingPrayerKey)
if lockEnabled { openUnlockWindow() }
}
private func openUnlockWindow() {
ShieldController.clearShields()
let end = SharedStore.nextTwoAM(after: Date())
SharedStore.defaults.set(end, forKey: SharedStore.unlockUntilKey)
unlockUntil = end
// Ask the system to wake our ActivityMonitor extension at 2 AM so the
// shields come back even if this app has been suspended all day.
let center = DeviceActivityCenter()
center.stopMonitoring([.unlockWindow])
let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: Calendar.current.dateComponents(units, from: Date()),
intervalEnd: Calendar.current.dateComponents(units, from: end),
repeats: false
)
do {
try center.startMonitoring(.unlockWindow, during: schedule)
} catch {
// e.g. praying at 1:55 AM leaves an interval under DeviceActivity's
// 15-minute floor. The foreground reconcile pass re-shields instead.
}
// Re-assert the daily 2 AM checkpoint alongside every window.
startDailyGuard()
}
/// Called whenever the app becomes active. Safety net for anything the
/// extensions missed, and the landing point for the shield's
/// "Go pray for my loved ones" button.
func reconcileOnForeground() {
authorizationStatus = AuthorizationCenter.shared.authorizationStatus
if lockEnabled {
// The window's truth comes from when they actually prayed (until
// the next 2 AM after it), with the stored marker as a fallback
// for windows opened by older builds.
let derived = SharedStore.activeUnlockWindowEnd()
let stored = (SharedStore.defaults.object(forKey: SharedStore.unlockUntilKey) as? Date)
.flatMap { $0 > Date() ? $0 : nil }
if let end = derived ?? stored {
// Window is genuinely open — make every piece of state agree,
// healing any wrongful re-lock from a spurious system event.
SharedStore.defaults.set(end, forKey: SharedStore.unlockUntilKey)
unlockUntil = end
ShieldController.clearShields()
} else {
// 2 AM has truly passed → shields go back up.
ShieldController.applyShields()
unlockUntil = nil
}
} else {
unlockUntil = SharedStore.defaults.object(forKey: SharedStore.unlockUntilKey) as? Date
}
// User tapped the shield button — land straight in the prayer popup.
if SharedStore.defaults.bool(forKey: SharedStore.pendingPrayerKey) {
SharedStore.defaults.set(false, forKey: SharedStore.pendingPrayerKey)
showPrayerFlow = true
}
}
}
// ================================================================
// FILE 3 of 11 · HomeView.swift
// The main screen: your streak, your people, and the lock status.
// ================================================================
//
// HomeView.swift
// Morning Prayer
//
import SwiftUI
import FamilyControls
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@State private var showingPicker = false
@State private var showingNames = false
@State private var namesEditorAutofocus = false
private var greeting: String {
switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return "Good morning"
case 12..<17: return "Good afternoon"
default: return "Good evening"
}
}
var body: some View {
NavigationStack {
ZStack {
Theme.skyGradient.ignoresSafeArea()
ScrollView {
VStack(spacing: 18) {
header
if !model.isAuthorized {
authorizationCard
}
heroCard
prayButton
lovedOnesCard
lockCard
}
.padding(20)
}
}
.sheet(isPresented: $model.showPrayerFlow) {
PrayerFlowView()
}
.sheet(isPresented: $showingNames) {
LovedOnesEditor(startAdding: namesEditorAutofocus)
}
.familyActivityPicker(isPresented: $showingPicker, selection: $model.selection)
}
}
// MARK: - Pieces
private var header: some View {
VStack(spacing: 10) {
SunriseLogo(size: 72)
Text(greeting)
.font(.system(.title, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
Text(model.prayedToday
? "You've already prayed today. Well done."
: "Start the day with the people you love.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
}
.padding(.top, 8)
}
private var authorizationCard: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Screen Time access needed", systemImage: "hourglass")
.font(.headline)
.foregroundStyle(Theme.cocoa)
Text("Prayer Lock uses Apple's Screen Time to rest the apps you choose until you've prayed. Which apps you pick stays private and on this device.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
Button {
Task { await model.requestAuthorization() }
} label: {
Text("Allow Screen Time Access")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.foregroundStyle(.white)
.background(Theme.coral, in: Capsule())
}
}
.card()
}
private var heroCard: some View {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Label("\(model.displayStreak)-day streak", systemImage: "flame.fill")
.font(.headline)
.foregroundStyle(Theme.ember)
Text(statusLine)
.font(.footnote)
.foregroundStyle(Theme.cocoaSoft)
}
Spacer()
Image(systemName: model.prayedToday ? "sun.max.fill" : "sunrise.fill")
.font(.system(size: 34))
.foregroundStyle(Theme.sunGradient)
}
.card()
}
private var statusLine: String {
if !model.lockEnabled { return "Prayer Lock is off" }
if model.isUnlocked { return "Apps are open until 2:00 AM" }
return "Apps are resting until you pray"
}
private var prayButton: some View {
Button {
model.showPrayerFlow = true
} label: {
Label("Pray for my loved ones", systemImage: "hands.sparkles.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(Theme.buttonGradient,
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: Theme.coral.opacity(0.4), radius: 12, y: 6)
}
}
private var lovedOnesCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Your loved ones")
.font(.headline)
.foregroundStyle(Theme.cocoa)
Spacer()
Button("Edit") {
namesEditorAutofocus = false
showingNames = true
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.coral)
}
if model.lovedOnes.isEmpty {
Text("Add the people you'll pray for each morning.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)],
alignment: .leading, spacing: 8) {
ForEach(model.lovedOnes, id: \.self) { name in
Text(name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.cocoa)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Theme.peach, in: Capsule())
.lineLimit(1)
}
addChip
}
}
.card()
}
/// The inline "+ Add" chip that sits right beside the name chips —
/// opens the editor with the keyboard already in the name field.
private var addChip: some View {
Button {
namesEditorAutofocus = true
showingNames = true
} label: {
Label("Add", systemImage: "plus")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.coral)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(
Capsule().strokeBorder(
Theme.coral.opacity(0.55),
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
)
}
}
private var lockCard: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle(isOn: Binding(
get: { model.lockEnabled },
set: { model.setLock(enabled: $0) }
)) {
Label("Prayer Lock", systemImage: "lock.shield")
.font(.headline)
.foregroundStyle(Theme.cocoa)
}
.tint(Theme.coral)
.disabled(!model.isAuthorized)
Button {
showingPicker = true
} label: {
HStack {
Text("Locked apps")
.foregroundStyle(Theme.cocoa)
Spacer()
Text(model.selectionSummary)
.foregroundStyle(Theme.cocoaSoft)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.cocoaSoft)
}
.font(.subheadline)
}
.buttonStyle(.plain)
.disabled(!model.isAuthorized)
if model.lockEnabled && model.isUnlocked {
HStack {
Label("Open until 2:00 AM", systemImage: "lock.open")
.font(.footnote)
.foregroundStyle(Theme.cocoaSoft)
Spacer()
Button("Re-lock now") { model.relockNow() }
.font(.footnote.weight(.semibold))
.foregroundStyle(Theme.coral)
}
}
}
.card()
}
}
// ================================================================
// FILE 4 of 11 · OnboardingView.swift
// The first-run flow: permissions, picking apps to lock, adding your people.
// ================================================================
//
// OnboardingView.swift
// Morning Prayer
//
// First-run story: why this exists → life gets crazy (emoji drift) →
// what the app does → Screen Time permission → who's on your heart.
//
import SwiftUI
import FamilyControls
struct OnboardingView: View {
@EnvironmentObject private var model: AppModel
@State private var step = 0
@State private var requesting = false
@State private var newName = ""
@State private var showingPicker = false
@State private var deniedOnce = false
private let stepCount = 6
var body: some View {
ZStack {
Theme.skyGradient.ignoresSafeArea()
if step == 1 {
EmojiRain().transition(.opacity)
}
VStack {
HStack {
if step > 0 {
Button {
step -= 1
} label: {
Image(systemName: "chevron.backward")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Theme.cocoaSoft)
.padding(10)
.background(.white.opacity(0.6), in: Circle())
}
}
Spacer()
}
.frame(height: 44)
Spacer(minLength: 0)
Group {
switch step {
case 0: powerStep
case 1: crazyStep
case 2: logoStep
case 3: screenTimeStep
case 4: lockStep
default: namesStep
}
}
.id(step)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)))
Spacer(minLength: 0)
dots
}
.padding(24)
}
.animation(.easeInOut(duration: 0.35), value: step)
.familyActivityPicker(isPresented: $showingPicker, selection: $model.selection)
.onChange(of: showingPicker) { _, showing in
// Returning from the picker with apps chosen = lock is set up.
if !showing && step == 4 && model.hasSelection {
model.lockAllApps = false
model.setLock(enabled: true)
step += 1
}
}
}
// MARK: - Steps
private var powerStep: some View {
VStack(spacing: 18) {
Image(systemName: "heart.fill")
.font(.system(size: 48))
.foregroundStyle(Theme.sunGradient)
Text("Praying for the people you love is one of the most powerful things you can do.")
.font(.system(.title2, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
.multilineTextAlignment(.center)
Text("Especially when they're going through hard times. We built Morning Prayer to help your phone connect you with God and the people you care about — instead of distracting you.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
primaryButton("Continue") { step += 1 }
.padding(.top, 8)
}
}
private var crazyStep: some View {
VStack(spacing: 14) {
Text("But life gets crazy.")
.font(.system(.title, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
.multilineTextAlignment(.center)
Text("Notifications, feeds, one more scroll… and we forget.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
primaryButton("Continue") { step += 1 }
.padding(.top, 8)
}
.padding(22)
.background(.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 26, style: .continuous))
}
private var logoStep: some View {
VStack(spacing: 20) {
SunriseLogo(size: 116)
Text("Morning Prayer")
.font(.system(.largeTitle, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
Text("Morning Prayer locks away the distractions until you've prayed for the people in your life who need your help.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
primaryButton("Continue") { step += 1 }
.padding(.top, 8)
}
}
private var screenTimeStep: some View {
VStack(spacing: 18) {
Image(systemName: "lock.shield")
.font(.system(size: 52))
.foregroundStyle(Theme.sunGradient)
Text("First, the lock")
.font(.system(.title2, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
Text("We use Apple's Screen Time to lock away the distractions until you've prayed. Which apps you choose stays private on your phone — we never see it.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
if model.isAuthorized {
Label("Screen Time access allowed", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.green)
primaryButton("Continue") { step += 1 }
} else {
primaryButton(requesting ? "Asking…" : "Allow Screen Time Access") {
guard !requesting else { return }
requesting = true
Task {
await model.requestAuthorization()
requesting = false
if model.isAuthorized { step += 1 } else { deniedOnce = true }
}
}
if deniedOnce {
Text("No prompt? iOS remembers a \u{201C}Don't Allow\u{201D} — deleting and reinstalling the app brings it back, or allow it later from the home screen.")
.font(.caption)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
}
Button("Not now — I'll do it later") { step += 1 }
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
}
}
}
private var lockStep: some View {
VStack(spacing: 16) {
Image(systemName: "apps.iphone")
.font(.system(size: 48))
.foregroundStyle(Theme.sunGradient)
Text("What should the lock cover?")
.font(.system(.title2, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
.multilineTextAlignment(.center)
if model.isAuthorized {
Text("Most people lock everything — mornings feel different when nothing can grab you before you've prayed. Calls still work, and the emergency pause is always one tap away.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
primaryButton("Lock all apps (recommended)") {
model.lockAllApps = true
model.setLock(enabled: true)
step += 1
}
Button("Choose specific apps") { showingPicker = true }
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.coral)
Button("Skip for now") { step += 1 }
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
} else {
Text("The lock needs Screen Time access — one tap to allow it, then choose what it covers.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
primaryButton(requesting ? "Asking…" : "Allow Screen Time Access") {
guard !requesting else { return }
requesting = true
Task {
await model.requestAuthorization()
requesting = false
if !model.isAuthorized { deniedOnce = true }
}
}
if deniedOnce {
Text("No prompt? iOS remembers a \u{201C}Don't Allow\u{201D} — deleting and reinstalling the app brings it back, or allow it later from the home screen.")
.font(.caption)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
}
Button("Skip for now") { step += 1 }
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
}
}
}
private var namesStep: some View {
VStack(spacing: 16) {
Text("Who's on your heart right now?")
.font(.system(.title2, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
.multilineTextAlignment(.center)
Text("Add the people you'll pray for each morning. First names are plenty.")
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
.multilineTextAlignment(.center)
if !model.lovedOnes.isEmpty {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)],
alignment: .center, spacing: 8) {
ForEach(model.lovedOnes, id: \.self) { name in
Text(name)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.cocoa)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Theme.peach, in: Capsule())
.lineLimit(1)
}
}
}
HStack {
TextField("Add a name", text: $newName)
.textFieldStyle(.roundedBorder)
.submitLabel(.done)
.onSubmit(addName)
Button(action: addName) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Theme.coral)
}
.disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
}
primaryButton("Start my mornings") { finish() }
.disabled(nothingEntered)
.opacity(nothingEntered ? 0.5 : 1)
Button("I'll add them later") { finish() }
.font(.subheadline)
.foregroundStyle(Theme.cocoaSoft)
}
}
// MARK: - Helpers
private var nothingEntered: Bool {
model.lovedOnes.isEmpty
&& newName.trimmingCharacters(in: .whitespaces).isEmpty
}
private func addName() {
let trimmed = newName.trimmingCharacters(in: .whitespaces)
newName = ""
guard !trimmed.isEmpty, !model.lovedOnes.contains(trimmed) else { return }
model.lovedOnes.append(trimmed)
}
private func finish() {
addName()
model.completeOnboarding()
}
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(Theme.buttonGradient,
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
}
private var dots: some View {
HStack(spacing: 8) {
ForEach(0..<stepCount, id: \.self) { i in
Circle()
.fill(i == step ? Theme.coral : Theme.peach)
.frame(width: 8, height: 8)
}
}
.padding(.bottom, 8)
}
}
/// Emojis drifting across the screen — "life gets crazy."
private struct EmojiRain: View {
private let emojis = ["📱", "💬", "📧", "📸", "🎮", "📺", "🛒", "😂",
"🏈", "🎵", "📰", "❤️", "☕️", "⏰"]
@State private var animate = false
var body: some View {
GeometryReader { geo in
ZStack {
ForEach(Array(emojis.enumerated()), id: \.offset) { i, emoji in
let lane = (Double(i) * 0.618).truncatingRemainder(dividingBy: 1.0)
Text(emoji)
.font(.system(size: 26 + CGFloat(i % 3) * 9))
.opacity(0.8)
.position(x: animate ? geo.size.width + 44 : -44,
y: geo.size.height * (0.06 + 0.88 * lane))
.animation(.linear(duration: Double(7 + (i % 5) * 2))
.repeatForever(autoreverses: false)
.delay(Double(i) * 0.35),
value: animate)
}
}
.onAppear { animate = true }
}
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
// ================================================================
// FILE 5 of 11 · PrayerFlowView.swift
// The morning prayer moment itself.
// ================================================================
//
// PrayerFlowView.swift
// Morning Prayer
//
// The whole prayer experience: one pop-up with the names of your loved
// ones, a few quiet seconds with a soft haptic pulse, then
// "I've prayed for my loved ones" — success haptic, ripple, streak screen.
//
import SwiftUI
import Combine
struct PrayerFlowView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var secondsLeft = 5
@State private var revealed = 1
@State private var finished = false
@State private var rippling = false
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private var names: [String] {
model.lovedOnes.isEmpty ? ["Your loved ones"] : model.lovedOnes
}
var body: some View {
ZStack {
Theme.skyGradient.ignoresSafeArea()
if finished {
streakView
} else {
prayingView
}
if rippling {
RippleRings()
}
}
.onReceive(timer) { _ in
guard !finished else { return }
if secondsLeft > 0 {
secondsLeft -= 1
// A gentle pulse for each quiet second of prayer.
Haptics.soft()
if secondsLeft == 0 { Haptics.light() } // "ready" cue
}
if revealed < names.count { revealed += 1 }
}
}
// MARK: - Praying
private var prayingView: some View {
VStack(spacing: 26) {
Spacer()
SunriseLogo(size: 84)
Text("Hold them in your heart")
.font(.system(.title2, design: .serif))
.bold()
.foregroundStyle(Theme.cocoa)
VStack(spacing: 14) {
ForEach(Array(names.enumerated()), id: \.offset) { index, name in
Text(name)
.font(.system(.title3, design: .serif))
.foregroundStyle(Theme.cocoa)
.opacity(index < revealed ? 1 : 0)
.animation(.easeIn(duration: 0.6), value: revealed)
}
}
Spacer()
VStack(spacing: 10) {
Button {
finish()
} label: {
Text("I've prayed for my loved ones")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(Theme.buttonGradient,
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
.disabled(secondsLeft > 0 || rippling)
.opacity(secondsLeft > 0 ? 0.45 : 1)
Text(secondsLeft > 0 ? "One quiet moment…" : " ")
.font(.caption)
.foregroundStyle(Theme.cocoaSoft)
}
}
.padding(24)
}
private func finish() {
guard !rippling else { return }
Haptics.success()
model.completePrayer()
rippling = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.85) {
withAnimation(.easeInOut(duration: 0.4)) { finished = true }
rippling = false
}
}
// MARK: - Streak celebration
private var weekDays: [(label: String, filled: Bool, isToday: Bool)] {
let calendar = Calendar.current
let symbols = calendar.veryShortWeekdaySymbols
return (0..<7).map { i in
let date = calendar.date(byAdding: .day, value: i - 6, to: Date()) ?? Date()
let weekday = calendar.component(.weekday, from: date) - 1
let daysAgo = 6 - i
return (symbols[weekday], daysAgo < model.displayStreak, daysAgo == 0)
}
}
private var streakView: some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: "flame.fill")
.font(.system(size: 44))
.foregroundStyle(Theme.sunGradient)
Text("Day \(model.displayStreak)")
.font(.system(size: 72, weight: .bold, design: .serif))
.foregroundStyle(Theme.cocoa)
Text("of praying for your loved ones")
.font(.system(.title3, design: .serif))
.foregroundStyle(Theme.cocoaSoft)
HStack(spacing: 12) {
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 6) {
Text(day.label)
.font(.caption2.weight(.semibold))
.foregroundStyle(Theme.cocoaSoft)
ZStack {
Circle()
.fill(day.filled ? AnyShapeStyle(Theme.sunGradient)
: AnyShapeStyle(Theme.peach.opacity(0.6)))
.frame(width: 30, height: 30)
if day.filled {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
}
.overlay {
if day.isToday {
Circle().stroke(Theme.ember, lineWidth: 2.5)
.frame(width: 38, height: 38)
}
}
}
}
}
.padding(.vertical, 16)
.padding(.horizontal, 18)
.background(.white.opacity(0.85),
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: Theme.cocoa.opacity(0.06), radius: 14, y: 6)
.padding(.top, 8)
Text("See you tomorrow morning.")
.font(.footnote)
.foregroundStyle(Theme.cocoaSoft)
Spacer()
Button {
dismiss()
} label: {
Text("Done")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(Theme.buttonGradient,
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
}
.padding(24)
}
}
/// Expanding rings that wash over the screen when the prayer completes.
private struct RippleRings: View {
@State private var expand = false
var body: some View {
ZStack {
ForEach(0..<3, id: \.self) { i in
Circle()
.stroke(Theme.coral.opacity(0.5), lineWidth: 4)
.frame(width: 120, height: 120)
.scaleEffect(expand ? 4.2 : 0.3)
.opacity(expand ? 0 : 0.75)
.animation(.easeOut(duration: 0.9).delay(Double(i) * 0.15),
value: expand)
}
}
.onAppear { expand = true }
.allowsHitTesting(false)
.ignoresSafeArea()
}
}
// ================================================================
// FILE 6 of 11 · SettingsView.swift
// Settings: edit your people and change which apps are locked.
// ================================================================
//
// SettingsView.swift
// Morning Prayer
//
// The only settings the app needs: the list of loved ones, plus the
// emergency pause. (Locked apps are managed from the home screen.)
//
import SwiftUI
struct LovedOnesEditor: View {
/// When true (the "+ Add" chip), the sheet opens with the keyboard
/// already focused in the name field.
var startAdding: Bool = false
@EnvironmentObject private var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var newName = ""
@FocusState private var nameFieldFocused: Bool
var body: some View {
NavigationStack {
List {
Section {
ForEach(model.lovedOnes, id: \.self) { name in
Text(name)
}
.onDelete { model.lovedOnes.remove(atOffsets: $0) }
HStack {
TextField("Add a name", text: $newName)
.focused($nameFieldFocused)
.submitLabel(.done)
.onSubmit(add)
Button(action: add) {
Image(systemName: "plus.circle.fill")
.foregroundStyle(Theme.coral)
}
.disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
}
} footer: {
Text("First names are plenty. These appear in your morning prayer and on the shield screen.")
}
if model.lockEnabled {
Section {
Button("Emergency: pause Prayer Lock", role: .destructive) {
model.setLock(enabled: false)
dismiss()
}
} footer: {
Text("Always one tap away — this is a morning ritual, not a cage. You can also revoke everything in Settings → Screen Time.")
}
}
}
.navigationTitle("Loved ones")
.navigationBarTitleDisplayMode(.inline)
// Whatever's still typed in the field gets saved on ANY way out:
// the Done button, the keyboard's done key, or swiping the sheet away.
.onDisappear(perform: add)
.onAppear {
if startAdding {
// Sheets need a beat before focus sticks.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
nameFieldFocused = true
}
}
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
add()
dismiss()
}
}
}
}
}
private func add() {
let trimmed = newName.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !model.lovedOnes.contains(trimmed) else { return }
model.lovedOnes.append(trimmed)
newName = ""
}
}
// ================================================================
// FILE 7 of 11 · Theme.swift
// Colors and styling.
// ================================================================
//
// Theme.swift
// Morning Prayer
//
// Warm sunrise palette + shared visual pieces. Friendly, light, inviting.
//
import SwiftUI
import UIKit
/// Tiny haptics helper — the prayer moment should be felt, not just seen.
enum Haptics {
static func soft() { UIImpactFeedbackGenerator(style: .soft).impactOccurred() }
static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) }
}
enum Theme {
// Sunrise palette
static let cream = Color(red: 1.00, green: 0.973, blue: 0.941) // #FFF8F0
static let peach = Color(red: 1.00, green: 0.894, blue: 0.796) // #FFE4CB
static let apricot = Color(red: 1.00, green: 0.788, blue: 0.573) // #FFC992
static let coral = Color(red: 0.957, green: 0.451, blue: 0.369) // #F4735E
static let ember = Color(red: 0.910, green: 0.365, blue: 0.278) // #E85D47
static let cocoa = Color(red: 0.290, green: 0.216, blue: 0.157) // #4A3728
static let cocoaSoft = Color(red: 0.290, green: 0.216, blue: 0.157).opacity(0.62)
static let skyGradient = LinearGradient(
colors: [cream, peach],
startPoint: .top, endPoint: .bottom)
static let sunGradient = LinearGradient(
colors: [apricot, coral],
startPoint: .topLeading, endPoint: .bottomTrailing)
static let buttonGradient = LinearGradient(
colors: [coral, ember],
startPoint: .leading, endPoint: .trailing)
}
/// The app mark, drawn natively so it's always crisp: a warm rising sun
/// holding a heart. (The PNG versions of this same mark are used for the
/// app icon and the shield screen, where UIKit images are required.)
struct SunriseLogo: View {
var size: CGFloat = 64
var body: some View {
ZStack {
Circle()
.fill(Theme.sunGradient)
Circle()
.fill(.white.opacity(0.28))
.scaleEffect(0.84)
Image(systemName: "heart.fill")
.font(.system(size: size * 0.38, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: size, height: size)
.shadow(color: Theme.coral.opacity(0.35), radius: size * 0.12, y: size * 0.05)
}
}
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white.opacity(0.85),
in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: Theme.cocoa.opacity(0.06), radius: 14, y: 6)
}
}
extension View {
func card() -> some View { modifier(CardStyle()) }
}
// ================================================================
// FILE 8 of 11 · Shared.swift
// Compiled into the app and all three Screen Time extensions.
// ================================================================
//
// Shared.swift
// Morning Prayer
//
// Compiled into the app AND all three Screen Time extensions.
// Keep this file free of SwiftUI — extensions can't use it.
//
import Foundation
import FamilyControls
import ManagedSettings
import DeviceActivity
// MARK: - Configuration
enum SharedConfig {
/// ⚠️ Must match the App Group enabled on ALL FOUR targets
/// (Signing & Capabilities → App Groups). If you register your own
/// group id there, change this string to match.
static let appGroup = "group.com.hako.ddprayer"
}
extension ManagedSettingsStore.Name {
/// One named settings store, shared by the app and every extension.
static let ddprayer = Self("ddprayer")
}
extension DeviceActivityName {
/// The access window that follows the morning prayer (ends at 2 AM).
static let unlockWindow = Self("unlockWindow")
/// Repeating daily checkpoints in the small hours — each an independent
/// chance for the system to launch our monitor and enforce the re-lock.
/// Enforcement is deliberately MORNING-ONLY: once someone prays and
/// unlocks, nothing runs later in the day that could touch their apps.
static let dailyGuard = Self("dailyGuard") // 2:00 AM
static let guard0215 = Self("guard0215") // 2:15 AM
static let guard0300 = Self("guard0300") // 3:00 AM
static let morningGuards: [DeviceActivityName] =
[.dailyGuard, .guard0215, .guard0300]
/// Morning usage watch (2 AM–noon): if a dead night left apps open, the
/// user's own scrolling in watched apps launches our monitor — the rescue
/// keyed to exactly what forgetful users actually do.
static let usageGuard = Self("usageGuard")
}
extension DeviceActivityEvent.Name {
static let morningUsage = Self("morningUsage")
}
// MARK: - Shared storage (App Group UserDefaults)
enum SharedStore {
static let defaults: UserDefaults =
UserDefaults(suiteName: SharedConfig.appGroup) ?? .standard
// Keys
static let selectionKey = "selection.v1"
static let lockEnabledKey = "lockEnabled"
static let unlockUntilKey = "unlockUntil"
static let pendingPrayerKey = "pendingPrayerRequested"
static let lovedOnesKey = "lovedOnes.v1"
static let streakKey = "streakCount"
static let lastPrayedDayKey = "lastPrayedDay"
static let lastPrayedAtKey = "lastPrayedAt"
static let onboardingCompleteKey = "onboardingComplete"
static let lockAllKey = "lockAllApps"
// MARK: Loved ones
static func loadLovedOnes() -> [String] {
defaults.stringArray(forKey: lovedOnesKey) ?? []
}
static func saveLovedOnes(_ names: [String]) {
defaults.set(names, forKey: lovedOnesKey)
}
/// "Sarah, Mom & Dad" — used on the shield and around the app.
static func lovedOnesPhrase() -> String {
let names = loadLovedOnes()
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
switch names.count {
case 0: return "your loved ones"
case 1: return names[0]
case 2: return "\(names[0]) & \(names[1])"
default: return names.dropLast().joined(separator: ", ") + " & " + names.last!
}
}
// MARK: Unlock window
/// The next 2:00 AM after `date` — the moment shields come back, so the
/// apps are resting again when you wake up.
static func nextTwoAM(after date: Date) -> Date {
Calendar.current.nextDate(
after: date,
matching: DateComponents(hour: 2, minute: 0),
matchingPolicy: .nextTime,
direction: .forward
) ?? date.addingTimeInterval(24 * 3600)
}
/// Ground truth for the unlock window, derived from the exact moment of
/// the last completed prayer: the window runs until the next 2 AM after
/// it. Returns nil when no window is currently open. This is what keeps
/// one spurious system event from wrongly re-locking a whole day.
static func activeUnlockWindowEnd() -> Date? {
guard let prayedAt = defaults.object(forKey: lastPrayedAtKey) as? Date else {
return nil
}
let end = nextTwoAM(after: prayedAt)
return Date() < end ? end : nil
}
// MARK: App selection
// FamilyActivitySelection is Codable; the tokens inside are opaque, so we
// never learn (or store) which actual apps the user picked.
static func saveSelection(_ selection: FamilyActivitySelection) {
if let data = try? JSONEncoder().encode(selection) {
defaults.set(data, forKey: selectionKey)
}
}
static func loadSelection() -> FamilyActivitySelection {
guard let data = defaults.data(forKey: selectionKey),
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
else { return FamilyActivitySelection() }
return selection
}
}
// MARK: - Shield control
enum ShieldController {
/// Apple's DEFAULT settings store. Earlier builds used a custom named
/// store; the default store is the most reliable path for custom shield
/// configurations, so we standardize on it. (.ddprayer is kept only so
/// the app can clear any shields the old named store left behind.)
static let store = ManagedSettingsStore()
/// Put Apple's shield over everything the user selected — or, in
/// "lock all apps" mode, over every category-shieldable app and site.
static func applyShields() {
if SharedStore.defaults.bool(forKey: SharedStore.lockAllKey) {
store.shield.applications = nil
store.shield.applicationCategories = .all()
store.shield.webDomains = nil
store.shield.webDomainCategories = .all()
} else {
let selection = SharedStore.loadSelection()
store.shield.applications =
selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
store.shield.applicationCategories =
selection.categoryTokens.isEmpty ? nil : .specific(selection.categoryTokens)
store.shield.webDomains =
selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens
store.shield.webDomainCategories = nil
}
SharedStore.defaults.removeObject(forKey: SharedStore.unlockUntilKey)
}
/// Remove every shield (prayer completed, or lock paused).
static func clearShields() {
store.shield.applications = nil
store.shield.applicationCategories = nil
store.shield.webDomains = nil
store.shield.webDomainCategories = nil
}
}
// ================================================================
// FILE 9 of 11 · ShieldConfigurationExtension.swift
// The lock screen you see over a blocked app.
// ================================================================
//
// ShieldConfigurationExtension.swift
// ShieldUI
//
// The shield keeps Apple's native dark look — only the icon (praying
// hands), the copy, and the orange action button are ours. This
// extension is sandboxed — no network, no arbitrary UI — so content
// comes from the bundle and local App Group storage only.
//
// Debug aid: filter Console.app for "ddprayer" to see whether iOS is
// actually asking this extension for the shield design.
//
import ManagedSettings
import ManagedSettingsUI
import UIKit
import os
private let logger = Logger(subsystem: "com.hako.ddprayer", category: "ShieldUI")
class ShieldConfigurationExtension: ShieldConfigurationDataSource {
private func prayerShield(_ source: String) -> ShieldConfiguration {
let icon = UIImage(named: "PrayingHands")
logger.info("Shield config requested (\(source, privacy: .public)); icon \(icon == nil ? "MISSING" : "loaded", privacy: .public)")
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterialDark,
backgroundColor: nil,
icon: icon,
title: ShieldConfiguration.Label(
text: "Time to pray for your loved ones",
color: .white),
subtitle: ShieldConfiguration.Label(
text: "When you're ready, take a moment to pray for those who need it.",
color: UIColor(white: 1.0, alpha: 0.75)),
primaryButtonLabel: ShieldConfiguration.Label(
text: "Ready to pray",
color: .white),
primaryButtonBackgroundColor: .systemOrange,
secondaryButtonLabel: ShieldConfiguration.Label(
text: "Not now",
color: UIColor(white: 1.0, alpha: 0.7))
)
}
override func configuration(shielding application: Application) -> ShieldConfiguration {
prayerShield("app")
}
override func configuration(shielding application: Application,
in category: ActivityCategory) -> ShieldConfiguration {
prayerShield("app-in-category")
}
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
prayerShield("web")
}
override func configuration(shielding webDomain: WebDomain,
in category: ActivityCategory) -> ShieldConfiguration {
prayerShield("web-in-category")
}
}
// ================================================================
// FILE 10 of 11 · ShieldActionExtension.swift
// Handles the button press on that lock screen.
// ================================================================
//
// ShieldActionExtension.swift
// ShieldAction
//
// Handles the shield's buttons. On iOS 26.5+ the primary button opens
// Daily Deployment Prayer directly via
// ShieldActionResponse.openParentalControlsApp — "parental controls app"
// is Apple's term for the controlling app, which under individual
// authorization is this app. On earlier iOS the button closes the
// blocked app and the user reopens us manually. Either way, a flag in
// the App Group makes the app land straight in the prayer flow.
//
import ManagedSettings
class ShieldActionExtension: ShieldActionDelegate {
private func respond(to action: ShieldAction,
completionHandler: @escaping (ShieldActionResponse) -> Void) {
switch action {
case .primaryButtonPressed:
// Flag first — the app reads it on activation and auto-opens
// the prayer flow, whichever path brings the user there.
SharedStore.defaults.set(true, forKey: SharedStore.pendingPrayerKey)
if #available(iOS 26.5, *) {
completionHandler(.openParentalControlsApp)
} else {
completionHandler(.close)
}
case .secondaryButtonPressed:
completionHandler(.close)
@unknown default:
completionHandler(.close)
}
}
override func handle(action: ShieldAction,
for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void) {
respond(to: action, completionHandler: completionHandler)
}
override func handle(action: ShieldAction,
for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void) {
respond(to: action, completionHandler: completionHandler)
}
override func handle(action: ShieldAction,
for category: ActivityCategoryToken,
completionHandler: @escaping (ShieldActionResponse) -> Void) {
respond(to: action, completionHandler: completionHandler)
}
}
// ================================================================
// FILE 11 of 11 · ActivityMonitorExtension.swift
// Woken by iOS when the access window ends, so the locks come back on their own.
// ================================================================
//
// ActivityMonitorExtension.swift
// ActivityMonitor
//
// Woken by the system when the post-prayer access window ends, so the
// shields come back even if the main app was suspended long ago.
//
import DeviceActivity
import ManagedSettings
class ActivityMonitorExtension: DeviceActivityMonitor {
/// The checkpoint: if no prayer window is genuinely open and the lock is
/// on, shields go up. Idempotent — safe to run at every opportunity the
/// system gives us, at any time of day.
private func enforceIfWindowClosed() {
if SharedStore.activeUnlockWindowEnd() == nil,
SharedStore.defaults.bool(forKey: SharedStore.lockEnabledKey) {
ShieldController.applyShields()
SharedStore.defaults.removeObject(forKey: SharedStore.unlockUntilKey)
}
}
/// Fires at 2:00, 2:15, and 3:00 AM daily — independent launch
/// opportunities before anyone's alarm goes off, and none after.
/// (The usage guard's 2:00 start doubles as one more.)
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
if DeviceActivityName.morningGuards.contains(activity) || activity == .usageGuard {
enforceIfWindowClosed()
}
}
/// Usage-triggered rescue: the system launches us when the user racks up
/// real usage in watched apps during the morning window. If they should
/// be locked, they get locked mid-scroll; if they prayed, this no-ops.
override func eventDidReachThreshold(_ event: DeviceActivityEvent.Name,
activity: DeviceActivityName) {
super.eventDidReachThreshold(event, activity: activity)
if activity == .usageGuard {
enforceIfWindowClosed()
}
}
override func intervalDidEnd(for activity: DeviceActivityName) {
super.intervalDidEnd(for: activity)
if activity == .unlockWindow {
// Spurious-fire guard WITH boundary tolerance: the legitimate
// 2 AM event can arrive a hair early, so only ignore fires that
// come while the window still has real time left.
if let end = SharedStore.activeUnlockWindowEnd(),
end.timeIntervalSinceNow > 5 * 60 {
return
}
if SharedStore.defaults.bool(forKey: SharedStore.lockEnabledKey) {
ShieldController.applyShields()
}
SharedStore.defaults.removeObject(forKey: SharedStore.unlockUntilKey)
}
// Guard-schedule interval ends are deliberately ignored — enforcement
// is morning-only by design.
}
}
License, and building it yourself
The code is MIT licensed. Short version: use it, change it, ship your own version; keep the copyright notice; no warranty. The full text is below, and a LICENSE file is included in the download.
To build it you'll need a Mac with Xcode. It's a standard SwiftUI project (one app plus three Screen Time extensions) targeting iOS 17 or later. Running it on your own phone works with a regular Apple developer account; distributing a Screen Time app publicly requires Apple's Family Controls entitlement, which you request from Apple. This page is kept in sync with the current build by hand.
MIT License Copyright (c) 2026 Hako Systems Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.