Skip to content

Commit 56c1b21

Browse files
committed
feat: max daily limit
1 parent d9e6c0b commit 56c1b21

46 files changed

Lines changed: 2490 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Normal.xcodeproj/project.pbxproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
SharedDTO.swift,
8080
SharedStore.swift,
8181
SharedStoreProviding.swift,
82+
UsageDTO.swift,
8283
);
8384
target = 2F4718642F5569C90013F94D /* NormalMonitor */;
8485
};

‎Normal/App/AppContainer.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ struct AppContainer: View {
1414
@State private var appReviewService: AppReviewService
1515
@State private var emergencyUnblockService: EmergencyUnblockService
1616
@State private var donationService = DonationService()
17+
@State private var usageLimitService: UsageLimitService
1718

1819
init(services: AppServices) {
1920
_screenTimeService = State(initialValue: services.screenTime)
2021
_timedUnblockService = State(initialValue: services.timedUnblock)
2122
_scheduleService = State(initialValue: services.schedule)
2223
_appReviewService = State(initialValue: services.appReview)
2324
_emergencyUnblockService = State(initialValue: services.emergencyUnblock)
25+
_usageLimitService = State(initialValue: services.usageLimit)
2426
}
2527

2628
var body: some View {
@@ -36,6 +38,7 @@ struct AppContainer: View {
3638
.environment(appReviewService)
3739
.environment(emergencyUnblockService)
3840
.environment(donationService)
41+
.environment(usageLimitService)
3942
.task { mirrorCustomDomainsEnabled() }
4043
.onChange(of: allSettings.first?.enableCustomDomains ?? false) { _, enabled in
4144
scheduleService.mirrorCustomDomainsEnabled(enabled)

‎Normal/App/AppServices.swift‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@ final class AppServices {
77
let schedule: ScheduleService
88
let appReview: AppReviewService
99
let emergencyUnblock: EmergencyUnblockService
10+
let usageLimit: UsageLimitService
1011

1112
init() {
12-
let screenTime = ScreenTimeService()
13-
self.screenTime = screenTime
14-
1513
if UITestSupport.isActive {
1614
let center = UITestDeviceActivityCenter()
1715
let store = SharedStore(
1816
defaults: UserDefaults(suiteName: "uitest-\(UUID().uuidString)")
1917
)
18+
let screenTime = ScreenTimeService(sharedStore: store)
19+
self.screenTime = screenTime
20+
usageLimit = UsageLimitService(
21+
activityCenter: center,
22+
sharedStore: store,
23+
ledger: InMemoryUsageLimitLedger()
24+
)
2025
timedUnblock = TimedUnblockService(
2126
activityCenter: center,
2227
sharedStore: store,
@@ -28,6 +33,9 @@ final class AppServices {
2833
)
2934
emergencyUnblock = EmergencyUnblockService(ledger: InMemoryEmergencyUnblockLedger())
3035
} else {
36+
let screenTime = ScreenTimeService()
37+
self.screenTime = screenTime
38+
usageLimit = UsageLimitService()
3139
timedUnblock = TimedUnblockService(onExpiration: { screenTime.notifyUpdate() })
3240
schedule = ScheduleService()
3341
appReview = AppReviewService()

‎Normal/App/ContentView.swift‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ struct ContentView: View {
77
@Environment(TimedUnblockService.self) private var timedUnblockService
88
@Environment(ScheduleService.self) private var scheduleService
99
@Environment(EmergencyUnblockService.self) private var emergencyUnblockService
10+
@Environment(UsageLimitService.self) private var usageLimitService
1011
@Environment(\.scenePhase) private var scenePhase
1112
@Query private var allSettings: [Settings]
1213
@Query private var schedules: [BlockSchedule]
14+
@Query private var usageLimits: [UsageLimit]
1315

1416
@State private var selectedTab: AppTab = .home
1517
@State private var navigationCoordinator = NavigationCoordinator()
@@ -33,12 +35,15 @@ struct ContentView: View {
3335
if let newTab { selectedTab = newTab }
3436
}
3537
.onChange(of: onboardingService.isOnboardingActive, onOnboardingCompleted)
38+
.onChange(of: usageLimits.count) { _, _ in reregisterActivities() }
3639
.onChange(of: scenePhase) { _, newPhase in
3740
if newPhase == .active {
3841
Task { await screenTimeService.checkAuthorizationStatus() }
3942
screenTimeService.notifyUpdate()
4043
timedUnblockService.refresh(screenTimeService: screenTimeService)
41-
reregisterSchedules()
44+
// The monitor extension may have spent a limit while we were away.
45+
usageLimitService.notifyUpdate()
46+
reregisterActivities()
4247
}
4348
}
4449
.onAppear(perform: onAppear)
@@ -59,14 +64,18 @@ struct ContentView: View {
5964
await screenTimeService.checkAuthorizationStatus()
6065
if settings?.hasCompletedOnboarding == true {
6166
_ = await screenTimeService.ensureAuthorized()
62-
reregisterSchedules()
67+
reregisterActivities()
6368
}
6469
}
6570
}
6671

67-
private func reregisterSchedules() {
68-
guard settings?.hasCompletedOnboarding == true else { return }
72+
/// Re-registers every `DeviceActivity` we depend on. Usage limits ride along
73+
/// with schedules: their threshold events use `includesPastActivity`, so
74+
/// re-registering mid-day cannot hand back an allowance already spent.
75+
private func reregisterActivities() {
76+
guard let settings, settings.hasCompletedOnboarding else { return }
6977
scheduleService.registerAll(schedules, screenTimeService: screenTimeService)
78+
usageLimitService.registerAll(usageLimits)
7079
}
7180

7281
private func onOnboardingCompleted(_: Bool, _ isActive: Bool) {

‎Normal/App/NormalApp.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ struct NormalApp: App {
1515
Key.self,
1616
BlockSchedule.self,
1717
Settings.self,
18+
UsageLimit.self,
1819
])
1920
let configuration = ModelConfiguration(
2021
schema: schema,

‎Normal/Models/BlockSchedule.swift‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ final class BlockSchedule: Identifiable {
4040
}
4141

4242
var formattedDuration: String {
43-
let hours = durationMinutes / 60
44-
let minutes = durationMinutes % 60
45-
if hours == 0 { return "\(minutes)m" }
46-
if minutes == 0 { return "\(hours)h" }
47-
return "\(hours)h \(minutes)m"
43+
DurationFormat.compact(minutes: durationMinutes)
4844
}
4945

5046
var weekdayLabels: [String] {

‎Normal/Models/UsageLimit.swift‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import FamilyControls
2+
import Foundation
3+
import SwiftData
4+
5+
/// A daily ceiling on real usage of a chosen set of apps.
6+
///
7+
/// The apps in one limit share its allowance, the way Screen Time's own App
8+
/// Limits work. When it runs out they are shielded again for the rest of the
9+
/// day, whatever else is unblocked.
10+
@Model
11+
final class UsageLimit: Identifiable {
12+
@Attribute(.unique) var id: UUID
13+
14+
var selection: FamilyActivitySelection
15+
var minutesPerDay: Int
16+
var sortIndex: Int = 0
17+
18+
/// Shortest allowance we offer. `DeviceActivity` thresholds below a few
19+
/// minutes fire unreliably, so the picker never goes lower.
20+
static let minimumMinutes = 5
21+
static let maximumMinutes = 23 * 60 + 55
22+
23+
init(
24+
selection: FamilyActivitySelection = FamilyActivitySelection(),
25+
minutesPerDay: Int,
26+
sortIndex: Int = 0
27+
) {
28+
id = UUID()
29+
self.selection = selection
30+
self.minutesPerDay = minutesPerDay
31+
self.sortIndex = sortIndex
32+
}
33+
34+
/// Flattens for the monitor extension, which must be able to re-shield
35+
/// without the app running.
36+
func toDTO() -> UsageLimitDTO? {
37+
guard let data = try? selection.toData() else { return nil }
38+
return UsageLimitDTO(id: id, selectionData: data, minutesPerDay: minutesPerDay)
39+
}
40+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Foundation
2+
3+
/// A proposed change to the usage limits, classified by direction.
4+
///
5+
/// Only *loosening* changes are rate limited — tightening is always immediate,
6+
/// so the commitment device never stands between you and more discipline.
7+
nonisolated enum UsageLimitEdit: Equatable, Sendable {
8+
case create(minutes: Int)
9+
/// `dropsCoverage` is true when apps were removed from what the limit
10+
/// meters — less watched is less restrictive, so it counts as loosening
11+
/// even if the allowance itself shrank.
12+
case adjust(from: Int, to: Int, dropsCoverage: Bool)
13+
case delete(minutes: Int)
14+
15+
var isLoosening: Bool {
16+
switch self {
17+
case .create: false
18+
case let .adjust(from, to, dropsCoverage): to > from || dropsCoverage
19+
case .delete: true
20+
}
21+
}
22+
}
23+
24+
/// Why a change was let through. Only `cooldownElapsed` starts a new week.
25+
nonisolated enum UsageLimitEditAllowance: Equatable, Sendable {
26+
case tightening
27+
case grace
28+
case cooldownElapsed
29+
}
30+
31+
nonisolated enum UsageLimitEditDecision: Equatable, Sendable {
32+
case allowed(UsageLimitEditAllowance)
33+
case locked(until: Date)
34+
35+
var isAllowed: Bool {
36+
if case .allowed = self { return true }
37+
return false
38+
}
39+
}
40+
41+
/// What the Usage screen shows before any change is proposed.
42+
nonisolated enum UsageLimitLockState: Equatable, Sendable {
43+
case unlocked
44+
case grace(until: Date)
45+
case locked(until: Date)
46+
47+
var lockedUntil: Date? {
48+
if case let .locked(until) = self { return until }
49+
return nil
50+
}
51+
}
52+
53+
/// Pure rules for when limits may be loosened.
54+
///
55+
/// The anchor is the moment of the last loosening and never moves during the
56+
/// grace window — otherwise repeated edits inside grace would walk the clock
57+
/// forward indefinitely and the weekly cooldown would never bite.
58+
nonisolated enum UsageLimitEditPolicy {
59+
static let graceInterval: TimeInterval = .minutes(10)
60+
static let cooldownInterval: TimeInterval = .days(7)
61+
62+
static func lockState(lastLoosenedAt: Date?, now: Date = .now) -> UsageLimitLockState {
63+
guard let anchor = lastLoosenedAt else { return .unlocked }
64+
let graceEnds = anchor + graceInterval
65+
if now < graceEnds { return .grace(until: graceEnds) }
66+
let unlock = anchor + cooldownInterval
67+
return now < unlock ? .locked(until: unlock) : .unlocked
68+
}
69+
70+
static func decide(
71+
_ edit: UsageLimitEdit,
72+
lastLoosenedAt: Date?,
73+
now: Date = .now
74+
) -> UsageLimitEditDecision {
75+
guard edit.isLoosening else { return .allowed(.tightening) }
76+
switch lockState(lastLoosenedAt: lastLoosenedAt, now: now) {
77+
case .unlocked: return .allowed(.cooldownElapsed)
78+
case .grace: return .allowed(.grace)
79+
case let .locked(until): return .locked(until: until)
80+
}
81+
}
82+
83+
/// The anchor to persist after a change was applied.
84+
static func anchor(
85+
after allowance: UsageLimitEditAllowance,
86+
current: Date?,
87+
now: Date = .now
88+
) -> Date? {
89+
allowance == .cooldownElapsed ? now : current
90+
}
91+
}

0 commit comments

Comments
 (0)