feat: enforce time and open limits in the background via Screen Time extensions

- Shared/ layer compiled into the app and three new extension targets:
  rule snapshots in the app group, the usage ledger, monitoring-plan
  naming, and LimitEnforcement (shared, unit-tested event reactions)
- RuleScheduler mirrors rules to the app group and reconciles
  DeviceActivity monitoring: one daily 00:00-23:59 activity per limit
  rule, with a cumulative usage-threshold event per budget minute for
  time limits; activities restart only when their configuration changes
  (a restart resets threshold accounting)
- OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets,
  records usage minutes, shields at the budget, re-shields when a
  granted open session ends
- OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times
  today' with an 'Open (Y left)' secondary button
- OpenAppLockShieldAction: an Open press spends one open, lifts the
  rule's shield, and starts the ~15-minute one-shot session
- extensions are classic NSExtension app extensions (the ExtensionKit
  product type expects an @main entry and made the app fail to install);
  shield-store tracking moved to app-group defaults so the app and
  extensions see one consistent set
- maps iOS 26's new .approvedWithDataAccess authorization status to
  approved (it previously fell through @unknown default to notDetermined)
- shares the OpenAppLock scheme (Xcode dropped the autocreated one when
  targets were added)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:06:08 -04:00
parent df6b7b689d
commit 443b37c5e1
29 changed files with 1686 additions and 24 deletions

19
Shared/AppGroup.swift Normal file
View File

@@ -0,0 +1,19 @@
//
// AppGroup.swift
// OpenAppLock
//
import Foundation
/// The app group shared by the app and its Screen Time extensions. Usage
/// tracking and rule snapshots live in its UserDefaults suite so the
/// DeviceActivity monitor and shield extensions can read and write them.
enum AppGroup {
static let identifier = "group.dev.bchen.OpenAppLock"
/// Shared defaults; falls back to standard defaults when the group
/// container is unavailable (e.g. entitlement not provisioned yet).
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

View File

@@ -0,0 +1,98 @@
//
// LimitEnforcement.swift
// OpenAppLock
//
import Foundation
/// Shared reactions to Screen Time events for limit rules, driven by the
/// snapshot store and usage ledger. The DeviceActivity monitor and shield
/// extensions call these; keeping the logic here makes it unit-testable from
/// the app target.
struct LimitEnforcement {
let snapshots: RuleSnapshotStore
let ledger: UsageLedger
let shields: ShieldApplying
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
/// proactively shielded on enabled days so the shield can count opens;
/// time-limit rules start the day unshielded.
func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
!snapshot.isPaused(at: now)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
switch snapshot.kind {
case .schedule:
break
case .openLimit:
if snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
case .timeLimit:
if snapshot.limitReached(given: usage),
snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
}
}
/// A cumulative usage checkpoint fired for a time-limit rule.
func handleUsageMinutes(
_ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar)
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .timeLimit,
!snapshot.isPaused(at: now),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
if snapshot.limitReached(given: usage) {
shield(snapshot)
}
}
/// The wall-clock session granted by an "Open" press ended; the shield
/// returns so the next open costs another press.
func handleOpenSessionEnded(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
shield(snapshot)
}
/// "Open" pressed on the shield. Spends one open and lifts the rule's
/// shield when the budget allows; returns whether a session was granted.
@discardableResult
func handleOpenRequest(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) -> Bool {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now)
else { return false }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
guard !snapshot.limitReached(given: usage) else { return false }
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
shields.clearShield(ruleID: ruleID)
return true
}
private func shield(_ snapshot: RuleSnapshot) {
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
mode: .block,
blockAdultContent: snapshot.blockAdultContent
)
}
}

View File

@@ -0,0 +1,61 @@
//
// MonitoringPlan.swift
// OpenAppLock
//
import Foundation
/// Naming conventions and event layouts shared by the app (which starts
/// DeviceActivity monitoring) and the monitor extension (which decodes what
/// fired).
enum MonitoringPlan {
private static let dailyPrefix = "rule-"
private static let sessionPrefix = "open-session-"
private static let minutePrefix = "minutes-"
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
/// minimum schedule interval, so an "open" lasts at most this long before
/// the shield returns.
static let openSessionMinutes = 15
/// The always-on, midnight-to-midnight activity tracking a rule's day.
static func dailyActivityName(for ruleID: UUID) -> String {
dailyPrefix + ruleID.uuidString
}
/// The one-shot activity timing a granted open.
static func sessionActivityName(for ruleID: UUID) -> String {
sessionPrefix + ruleID.uuidString
}
static func ruleID(fromDailyActivityName name: String) -> UUID? {
guard name.hasPrefix(dailyPrefix) else { return nil }
return UUID(uuidString: String(name.dropFirst(dailyPrefix.count)))
}
static func ruleID(fromSessionActivityName name: String) -> UUID? {
guard name.hasPrefix(sessionPrefix) else { return nil }
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
}
static func minuteEventName(for minutes: Int) -> String {
minutePrefix + String(minutes)
}
static func minutes(fromEventName name: String) -> Int? {
guard name.hasPrefix(minutePrefix) else { return nil }
return Int(name.dropFirst(minutePrefix.count))
}
/// Cumulative-usage checkpoints for a time-limit rule: one event per
/// minute up to the budget so remaining time can be displayed live; the
/// final one doubles as the block trigger. (Budgets cap at 240 minutes,
/// comfortably inside DeviceActivity's event capacity.)
static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] {
Dictionary(
uniqueKeysWithValues: (1...max(1, limitMinutes)).map {
(minuteEventName(for: $0), $0)
}
)
}
}

64
Shared/RuleKind.swift Normal file
View File

@@ -0,0 +1,64 @@
//
// RuleKind.swift
// OpenAppLock
//
import Foundation
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
enum RuleKind: String, Codable, CaseIterable, Sendable {
/// Block selected apps during a recurring time window.
case schedule
/// Block selected apps after a daily usage budget is spent.
case timeLimit
/// Block selected apps after a number of opens per day.
case openLimit
var displayName: String {
switch self {
case .schedule: "Schedule"
case .timeLimit: "Time Limit"
case .openLimit: "Open Limit"
}
}
var exampleText: String {
switch self {
case .schedule: "e.g. 9-5, Daily"
case .timeLimit: "e.g. 45m/day"
case .openLimit: "e.g. 5 opens/day"
}
}
var symbolName: String {
switch self {
case .schedule: "calendar"
case .timeLimit: "hourglass"
case .openLimit: "lock.fill"
}
}
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
var defaultRuleName: String {
switch self {
case .schedule: "In the Zone"
case .timeLimit: "Time Keeper"
case .openLimit: "Gate Keeper"
}
}
}
/// How the rule's app selection is interpreted.
enum SelectionMode: String, Codable, CaseIterable, Sendable {
/// Block the selected apps; everything else stays available.
case block
/// Block everything except the selected apps.
case allowOnly
var displayName: String {
switch self {
case .block: "Block"
case .allowOnly: "Allow Only"
}
}
}

89
Shared/RuleSchedule.swift Normal file
View File

@@ -0,0 +1,89 @@
//
// RuleSchedule.swift
// OpenAppLock
//
import Foundation
/// The recurring time window of a rule, independent of any persistence.
///
/// A window whose end is at or before its start crosses midnight: 22:00 06:00
/// starts on an enabled day and ends the following morning. `start == end`
/// means a full 24-hour window.
struct RuleSchedule: Hashable, Sendable {
var startMinutes: Int
var endMinutes: Int
var days: Set<Weekday>
var crossesMidnight: Bool { endMinutes <= startMinutes }
var durationMinutes: Int {
crossesMidnight
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
: endMinutes - startMinutes
}
/// The window containing `date`, if the schedule is active at that moment.
///
/// Checks today's window and, for midnight-crossing schedules, the window
/// that started yesterday. The day a window *starts* on is the day that
/// must be enabled.
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
for dayOffset in [0, -1] {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start <= date, date < window.end
else { continue }
return window
}
return nil
}
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
activeWindow(containing: date, calendar: calendar) != nil
}
/// The next moment the schedule will begin a window strictly after `date`.
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
guard !days.isEmpty else { return nil }
for dayOffset in 0...7 {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start > date
else { continue }
return window.start
}
return nil
}
/// The window starting on the given day, or nil when that weekday is not enabled.
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
let dayStart = calendar.startOfDay(for: day)
guard
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
days.contains(weekday),
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
let end = calendar.date(
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
)
else { return nil }
return DateInterval(start: start, end: end)
}
private static let minutesPerDay = 24 * 60
}
extension RuleSchedule {
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
static func timeLabel(forMinutes minutes: Int) -> String {
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
}
/// "09:00 17:00" range label used by rule details and preset cards.
var timeRangeLabel: String {
"\(Self.timeLabel(forMinutes: startMinutes)) \(Self.timeLabel(forMinutes: endMinutes))"
}
}

75
Shared/RuleSnapshot.swift Normal file
View File

@@ -0,0 +1,75 @@
//
// RuleSnapshot.swift
// OpenAppLock
//
import Foundation
/// Codable mirror of a rule, written to the app group by the app whenever
/// rules change so the Screen Time extensions (which cannot open the
/// SwiftData store) know what to enforce.
struct RuleSnapshot: Codable, Equatable {
var id: UUID
var name: String
var kindRaw: String
var isEnabled: Bool
var hardMode: Bool
var blockAdultContent: Bool
var selectionModeRaw: String
var selectionData: Data?
var dayNumbers: [Int]
var dailyLimitMinutes: Int
var maxOpens: Int
var pausedUntil: Date?
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
return false
}
return days.contains(weekday)
}
/// Whether the given usage exhausts this rule's daily budget.
func limitReached(given usage: RuleUsage) -> Bool {
switch kind {
case .schedule: false
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
case .openLimit: usage.opensUsed >= maxOpens
}
}
/// Whether the user unblocked this rule for the rest of the day.
func isPaused(at now: Date) -> Bool {
guard let pausedUntil else { return false }
return pausedUntil > now
}
}
/// Persistence for the rule mirror in the shared app-group defaults.
final class RuleSnapshotStore {
private static let key = "ruleSnapshots"
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
func save(_ snapshots: [RuleSnapshot]) {
guard let data = try? JSONEncoder().encode(snapshots) else { return }
defaults.set(data, forKey: Self.key)
}
func load() -> [RuleSnapshot] {
guard let data = defaults.data(forKey: Self.key),
let snapshots = try? JSONDecoder().decode([RuleSnapshot].self, from: data)
else { return [] }
return snapshots
}
func snapshot(for ruleID: UUID) -> RuleSnapshot? {
load().first { $0.id == ruleID }
}
}

View File

@@ -0,0 +1,139 @@
//
// ShieldController.swift
// OpenAppLock
//
import FamilyControls
import Foundation
import ManagedSettings
/// Applies and clears app shields for rules. One implementation talks to
/// ManagedSettings; the mock records calls for tests.
protocol ShieldApplying: AnyObject {
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
)
/// Clears the shield of a single rule (used by the extensions for day
/// resets and granted opens).
func clearShield(ruleID: UUID)
/// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>)
}
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
/// are tracked in the shared app-group defaults (ManagedSettings cannot
/// enumerate stores) so the app and extensions see one consistent set.
final class ManagedSettingsShieldController: ShieldApplying {
private static let trackedIDsKey = "shieldedRuleIDs"
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) {
let store = store(for: ruleID)
let selection = AppSelectionCodec.decode(selectionData)
switch mode {
case .block:
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
case .allowOnly:
store.shield.applicationCategories = .all(except: selection.applicationTokens)
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
}
// Screen Time's "Limit Adult Websites" filter for the rule's lifetime.
store.webContent.blockedByFilter = blockAdultContent ? .auto() : nil
track(ruleID: ruleID)
}
func clearShield(ruleID: UUID) {
store(for: ruleID).clearAllSettings()
untrack(ruleID: ruleID)
}
func clearShields(except activeRuleIDs: Set<UUID>) {
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
clearShield(ruleID: ruleID)
}
}
private func store(for ruleID: UUID) -> ManagedSettingsStore {
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
}
private var trackedIDs: Set<UUID> {
Set((defaults.stringArray(forKey: Self.trackedIDsKey) ?? []).compactMap(UUID.init))
}
private func track(ruleID: UUID) {
let ids = trackedIDs.union([ruleID])
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
}
private func untrack(ruleID: UUID) {
let ids = trackedIDs.subtracting([ruleID])
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
}
}
/// Records shield operations without touching the system. Used by tests and
/// UI-test launches.
final class MockShieldController: ShieldApplying {
private(set) var shieldedRuleIDs: Set<UUID> = []
private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:]
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) {
shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode
appliedAdultContentFlags[ruleID] = blockAdultContent
appliedSelectionData[ruleID] = selectionData
}
func clearShield(ruleID: UUID) {
shieldedRuleIDs.remove(ruleID)
appliedModes[ruleID] = nil
appliedAdultContentFlags[ruleID] = nil
appliedSelectionData[ruleID] = nil
}
func clearShields(except activeRuleIDs: Set<UUID>) {
shieldedRuleIDs.formIntersection(activeRuleIDs)
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
appliedAdultContentFlags = appliedAdultContentFlags.filter {
activeRuleIDs.contains($0.key)
}
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
}
}
/// Encodes/decodes `FamilyActivitySelection` for persistence on the rule model.
enum AppSelectionCodec {
static func encode(_ selection: FamilyActivitySelection) -> Data? {
try? JSONEncoder().encode(selection)
}
static func decode(_ data: Data?) -> FamilyActivitySelection {
guard let data,
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
else { return FamilyActivitySelection() }
return selection
}
static func count(of selection: FamilyActivitySelection) -> Int {
selection.applicationTokens.count
+ selection.categoryTokens.count
+ selection.webDomainTokens.count
}
}

44
Shared/ShieldLookup.swift Normal file
View File

@@ -0,0 +1,44 @@
//
// ShieldLookup.swift
// OpenAppLock
//
import FamilyControls
import Foundation
import ManagedSettings
/// Matches shielded tokens back to the rule that shields them, so the shield
/// UI can offer "Open" with the right counts for open-limit rules.
enum ShieldLookup {
static func openLimitSnapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
static func openLimitSnapshot(
containingCategory token: ActivityCategoryToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.kind == .openLimit, snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.categoryTokens.contains(token)
}
}
/// Any enabled rule whose selection includes the application (for naming
/// plain blocked shields).
static func snapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
}

93
Shared/UsageLedger.swift Normal file
View File

@@ -0,0 +1,93 @@
//
// UsageLedger.swift
// OpenAppLock
//
import Foundation
/// What a limit rule has consumed on a given day. Written by the
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
/// read by the app for display and enforcement.
struct RuleUsage: Codable, Equatable {
var minutesUsed = 0
var opensUsed = 0
}
/// Read access to per-rule, per-day usage.
protocol UsageReading: AnyObject {
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
}
extension UsageReading {
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
usage(for: ruleID, onDayContaining: date, calendar: .current)
}
}
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
/// and rule. Old days are simply ignored; midnight needs no reset step.
final class UsageLedger: UsageReading {
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
/// "2026-06-12" calendar-date key so budgets roll over at midnight.
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
}
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
else { return RuleUsage() }
return usage
}
func setUsage(
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
guard let data = try? JSONEncoder().encode(usage) else { return }
defaults.set(data, forKey: key(ruleID, date, calendar))
}
/// Threshold events report cumulative totals, so minutes only move up.
func recordMinutesUsed(
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.minutesUsed = max(usage.minutesUsed, minutes)
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
}
@discardableResult
func recordOpen(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.opensUsed += 1
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
return usage
}
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
}
}
/// Seedable in-memory usage for tests and UI-test scenarios.
final class MockUsageLedger: UsageReading {
var usageByRule: [UUID: RuleUsage] = [:]
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
) -> RuleUsage {
usageByRule[ruleID] ?? RuleUsage()
}
}

65
Shared/Weekday.swift Normal file
View File

@@ -0,0 +1,65 @@
//
// Weekday.swift
// OpenAppLock
//
import Foundation
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday 7 = Saturday).
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
case sunday = 1
case monday = 2
case tuesday = 3
case wednesday = 4
case thursday = 5
case friday = 6
case saturday = 7
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
static let weekends: Set<Weekday> = [.saturday, .sunday]
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
static let displayOrder: [Weekday] = [
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
]
/// Single-letter label for the circular day toggles.
var shortLabel: String {
switch self {
case .sunday, .saturday: "S"
case .monday: "M"
case .tuesday, .thursday: "T"
case .wednesday: "W"
case .friday: "F"
}
}
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
var abbreviation: String {
switch self {
case .sunday: "Sun"
case .monday: "Mon"
case .tuesday: "Tue"
case .wednesday: "Wed"
case .thursday: "Thu"
case .friday: "Fri"
case .saturday: "Sat"
}
}
}
extension Set<Weekday> {
/// Human-readable summary shown next to the day picker and in rule details:
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
var summary: String {
if self == Weekday.everyDay { return "Every day" }
if self == Weekday.weekdays { return "Weekdays" }
if self == Weekday.weekends { return "Weekends" }
if isEmpty { return "Never" }
return Weekday.displayOrder
.filter(contains)
.map(\.abbreviation)
.joined(separator: ", ")
}
}