feat: re-skin to native three-tab UI and add Uninstall Protection

Replace the custom themed presentation with the bare native iOS design
language across a three-tab TabView (Home / Rules / Settings):

- MainTabView hosts the enforcement lifecycle (refresh loop, rule-change
  and scene-active reconcile) so it runs regardless of the selected tab
- HomeView: "Currently Blocking" + "Usage" sections (replaces AppsHomeView)
- RulesListView: rules grouped into Schedule / Time Limit / Open Limit
- SettingsView: Uninstall Protection toggle + Manage App Lists
- AppListLibraryView / ManageAppListsView: shared App List management
- AppSettings: app-group-backed settings store

Uninstall Protection: while enabled and any Hard Mode rule is actively
blocking, deny device app-removal via a dedicated ManagedSettingsStore
(RulePolicy.shouldDenyAppRemoval -> RuleEnforcer -> ShieldController).

Slim AppListPickerSheet to the picker role; drop AppsHomeView and the
custom Theme/HoldToCommitButton/RuleCardView chrome. Update spec section 6
and the unit/UI test suites accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:17:08 -04:00
parent 0a0d00f53a
commit 32b1694e0a
26 changed files with 1039 additions and 427 deletions

View File

@@ -59,6 +59,17 @@ enum RulePolicy {
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
}
/// Whether *any* rule is currently a hard block the condition that locks
/// app-list editing and (when the user opts in) device app removal.
static func isAnyHardLocked(
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
rules.contains {
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
}
}
/// App lists feed active shields, so while any hard-mode rule is actively
/// blocking, no list may be edited or deleted changing a list would be a
/// back door out of the hard block. Creating new lists and picking lists
@@ -67,9 +78,19 @@ enum RulePolicy {
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!rules.contains {
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
}
!isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
}
/// Whether the device's app removal should be denied right now: the user has
/// turned on Uninstall Protection *and* a hard block is currently in force.
/// Engaging this while a hard rule blocks makes the block harder to escape
/// (the user can't delete the locked apps or OpenAppLock itself).
static func shouldDenyAppRemoval(
rules: [BlockingRule], enabled: Bool,
usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
enabled && isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
}
/// Pauses the rule's current block. Returns false (and changes nothing)

View File

@@ -8,6 +8,15 @@ import Foundation
/// Strings for the home screen's Usage section. Used values clamp to the
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
enum UsageDisplay {
/// The usage subtitle prefixed with the rule's type, so the kind is clear
/// without relying on an icon: "Time Limit · 18m of 45m used today".
/// Schedule rules (no usage text) fall back to just the type name.
static func typedSubtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
let usageText = subtitle(for: rule, usage: usage)
guard !usageText.isEmpty else { return rule.kind.displayName }
return "\(rule.kind.displayName) · \(usageText)"
}
/// "18m of 45m used today" / "2 of 5 opens today".
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
switch rule.configuration {

View File

@@ -13,6 +13,7 @@ struct OpenAppLockApp: App {
private let container: ModelContainer
@State private var authorization: ScreenTimeAuthorization
@State private var enforcer: RuleEnforcer
@State private var settings: AppSettingsStore
init() {
let config = LaunchConfiguration.current
@@ -21,6 +22,15 @@ struct OpenAppLockApp: App {
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
}
// The app-group suite persists across UI-test launches (unlike the
// in-memory SwiftData store), so start each UI-test run from a clean
// Uninstall Protection state.
if config.isUITesting {
AppSettingsStore.resetForTesting()
}
let appSettings = AppSettingsStore()
_settings = State(initialValue: appSettings)
let schema = Schema([BlockingRule.self, AppList.self])
let modelConfiguration = ModelConfiguration(
schema: schema, isStoredInMemoryOnly: config.isUITesting
@@ -58,7 +68,7 @@ struct OpenAppLockApp: App {
_enforcer = State(
initialValue: RuleEnforcer(
shields: shields, usage: usageLedger, scheduler: scheduler,
openSessions: openSessions))
openSessions: openSessions, settings: appSettings))
}
var body: some Scene {
@@ -66,6 +76,7 @@ struct OpenAppLockApp: App {
RootView()
.environment(authorization)
.environment(enforcer)
.environment(settings)
}
.modelContainer(container)
}

View File

@@ -0,0 +1,53 @@
//
// AppSettings.swift
// OpenAppLock
//
import Foundation
import Observation
/// Read access to the app-wide settings the enforcer consults. Injected into
/// `RuleEnforcer` so the uninstall-protection decision has a single,
/// test-mockable source of truth.
protocol AppSettingsReading: AnyObject {
/// Whether the user has opted into denying device app removal while a Hard
/// Mode rule is actively blocking.
var uninstallProtectionEnabled: Bool { get }
}
/// The settings store, backed by the shared app-group defaults so the value
/// persists across launches (and is reachable by the extensions in future).
/// Observable so the Settings screen's toggle stays in sync.
@Observable
final class AppSettingsStore: AppSettingsReading {
static let uninstallProtectionKey = "uninstallProtectionEnabled"
@ObservationIgnored private let defaults: UserDefaults
var uninstallProtectionEnabled: Bool {
didSet {
defaults.set(uninstallProtectionEnabled, forKey: Self.uninstallProtectionKey)
}
}
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
// Property observers don't fire during init, so this load doesn't write back.
self.uninstallProtectionEnabled = defaults.bool(forKey: Self.uninstallProtectionKey)
}
/// Clears the persisted value used by UI-test launches, since the
/// app-group suite is not wiped between runs the way the SwiftData store is.
static func resetForTesting(defaults: UserDefaults = AppGroup.defaults) {
defaults.removeObject(forKey: uninstallProtectionKey)
}
}
/// In-memory settings for unit tests.
final class MockAppSettings: AppSettingsReading {
var uninstallProtectionEnabled: Bool
init(uninstallProtectionEnabled: Bool = false) {
self.uninstallProtectionEnabled = uninstallProtectionEnabled
}
}

View File

@@ -27,16 +27,21 @@ final class RuleEnforcer {
/// Granted-open sessions, so a proactively-gated open-limit rule is left
/// un-shielded while the user is inside a session they paid an open for.
private let openSessions: OpenSessionReading
/// App-wide settings (currently just Uninstall Protection) consulted on
/// every refresh.
private let settings: any AppSettingsReading
init(
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
scheduler: RuleScheduler? = nil,
openSessions: OpenSessionReading = OpenSessionStore()
openSessions: OpenSessionReading = OpenSessionStore(),
settings: any AppSettingsReading = AppSettingsStore()
) {
self.shields = shields
self.usageReader = usage
self.scheduler = scheduler
self.openSessions = openSessions
self.settings = settings
}
/// The day's usage for a rule (nil for schedule rules, which don't track).
@@ -84,6 +89,14 @@ final class RuleEnforcer {
// "Blocked Apps" lists only rules whose budget/window is spent not the
// proactive open-limit gate, which surfaces under "Usage" instead.
blockingRuleIDs = blocking
// Uninstall Protection: deny device app removal while the user has opted
// in and any Hard Mode rule is actively blocking.
shields.setAppRemovalDenied(
RulePolicy.shouldDenyAppRemoval(
rules: rules,
enabled: settings.uninstallProtectionEnabled,
usageFor: { usage(for: $0, at: now, calendar: calendar) },
at: now, calendar: calendar))
scheduler?.sync(rules: rules, at: now)
}

View File

@@ -0,0 +1,154 @@
//
// AppListLibraryView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// The reusable app-list library: the saved lists, an Edit affordance, the New
/// List flow, swipe-to-delete, and the Hard Mode lock. Used in two modes:
///
/// - **Picker** (`selection` non-nil): each row shows a checkmark and tapping it
/// selects the list and calls `onPick` (the rule editor uses this to dismiss).
/// Creating a list selects it without dismissing.
/// - **Management** (`selection` nil): no checkmark; tapping a row (when unlocked)
/// opens it for editing. Used by Settings Manage App Lists.
///
/// In both modes editing and deletion are disabled while any Hard Mode rule is
/// actively blocking changing a list would be a back door out of the block.
struct AppListLibraryView: View {
/// Picker mode when non-nil; management mode when nil.
var selection: Binding<AppList?>?
/// Called after a row is tapped in picker mode (e.g. to dismiss the sheet).
var onPick: (() -> Void)?
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \AppList.createdAt) private var lists: [AppList]
@Query private var rules: [BlockingRule]
@State private var editingList: AppList?
@State private var creatingList = false
@State private var deletionBlocked = false
private var isPicking: Bool { selection != nil }
/// While any hard-mode rule is actively blocking, lists are read-only.
private var listsLocked: Bool {
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
}
var body: some View {
List {
Section {
if lists.isEmpty {
Text("No app lists yet. Create one to choose which apps a rule affects.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptyAppListsLabel")
} else {
ForEach(lists) { list in
listRow(list)
}
}
} header: {
Text("Your App Lists").textCase(nil)
} footer: {
if listsLocked {
Label(
"Hard Mode is on — app lists are locked until the block ends.",
systemImage: "lock.fill"
)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("appListsLockedNotice")
}
}
Section {
Button {
creatingList = true
} label: {
Label("New List", systemImage: "plus")
}
.accessibilityIdentifier("newAppListButton")
}
}
.navigationDestination(isPresented: $creatingList) {
AppListEditorView(list: nil) { created in
selection?.wrappedValue = created
creatingList = false
}
}
.navigationDestination(item: $editingList) { list in
AppListEditorView(list: list) { _ in
editingList = nil
}
}
.alert("This list is in use", isPresented: $deletionBlocked) {
Button("OK", role: .cancel) {}
} message: {
Text("Remove it from the rules that use it before deleting.")
}
}
private func listRow(_ list: AppList) -> some View {
HStack {
Button {
if isPicking {
selection?.wrappedValue = list
onPick?()
} else if !listsLocked {
editingList = list
}
} label: {
HStack {
if isPicking {
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
)
.frame(width: 28)
}
VStack(alignment: .leading, spacing: 2) {
Text(list.name)
.foregroundStyle(Color.primary)
Text(list.appCountLabel)
.font(.caption)
.foregroundStyle(Color.secondary)
}
}
}
.accessibilityIdentifier("appListRow-\(list.name)")
Spacer()
if !listsLocked {
Button("Edit") {
editingList = list
}
.font(.subheadline)
.accessibilityIdentifier("editAppListButton-\(list.name)")
}
}
.buttonStyle(.borderless)
.swipeActions {
if !listsLocked {
Button("Delete", role: .destructive) {
delete(list)
}
}
}
}
private func isSelected(_ list: AppList) -> Bool {
selection?.wrappedValue?.id == list.id
}
private func delete(_ list: AppList) {
guard !AppList.isInUse(list, context: modelContext) else {
deletionBlocked = true
return
}
if isSelected(list) {
selection?.wrappedValue = nil
}
modelContext.delete(list)
}
}

View File

@@ -3,151 +3,31 @@
// OpenAppLock
//
import SwiftData
import SwiftUI
/// Chooses the app list a rule uses: saved lists (tap to select), an Edit
/// affordance per list, and a "New List" flow. Lists are persisted directly,
/// independent of any rule, so creating one here never depends on the rule
/// draft being committed.
/// Chooses the app list a rule uses. Wraps the shared `AppListLibraryView` in
/// picker mode (checkmark + select-and-dismiss); the library handles the list
/// rows, Edit/New flows, deletion, and the Hard Mode lock.
struct AppListPickerSheet: View {
@Binding var selected: AppList?
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \AppList.createdAt) private var lists: [AppList]
@Query private var rules: [BlockingRule]
@State private var editingList: AppList?
@State private var creatingList = false
@State private var deletionBlocked = false
/// While any hard-mode rule is actively blocking (by the clock or a spent
/// limit budget), lists are read-only editing one would be a back door
/// out of the hard block.
private var listsLocked: Bool {
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
}
var body: some View {
NavigationStack {
List {
Section {
if lists.isEmpty {
Text("No app lists yet. Create one to choose which apps this rule affects.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptyAppListsLabel")
} else {
ForEach(lists) { list in
listRow(list)
AppListLibraryView(selection: $selected, onPick: { dismiss() })
.navigationTitle("App List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") {
dismiss()
}
}
} header: {
Text("Your App Lists").textCase(nil)
} footer: {
if listsLocked {
Label(
"Hard Mode is on — app lists are locked until the block ends.",
systemImage: "lock.fill"
)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("appListsLockedNotice")
.accessibilityIdentifier("closeAppListPickerButton")
}
}
Section {
Button {
creatingList = true
} label: {
Label("New List", systemImage: "plus")
}
.accessibilityIdentifier("newAppListButton")
}
}
.navigationTitle("App List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeAppListPickerButton")
}
}
.navigationDestination(isPresented: $creatingList) {
AppListEditorView(list: nil) { created in
selected = created
creatingList = false
}
}
.navigationDestination(item: $editingList) { list in
AppListEditorView(list: list) { _ in
editingList = nil
}
}
.alert("This list is in use", isPresented: $deletionBlocked) {
Button("OK", role: .cancel) {}
} message: {
Text("Remove it from the rules that use it before deleting.")
}
}
}
private func listRow(_ list: AppList) -> some View {
HStack {
Button {
selected = list
dismiss()
} label: {
HStack {
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(list.name)
.foregroundStyle(Color.primary)
Text(list.appCountLabel)
.font(.caption)
.foregroundStyle(Color.secondary)
}
}
}
.accessibilityIdentifier("appListRow-\(list.name)")
Spacer()
if !listsLocked {
Button("Edit") {
editingList = list
}
.font(.subheadline)
.accessibilityIdentifier("editAppListButton-\(list.name)")
}
}
.buttonStyle(.borderless)
.swipeActions {
if !listsLocked {
Button("Delete", role: .destructive) {
delete(list)
}
}
}
}
private func isSelected(_ list: AppList) -> Bool {
selected?.id == list.id
}
private func delete(_ list: AppList) {
guard !AppList.isInUse(list, context: modelContext) else {
deletionBlocked = true
return
}
if isSelected(list) {
selected = nil
}
modelContext.delete(list)
}
}
extension AppList {

View File

@@ -0,0 +1,17 @@
//
// ManageAppListsView.swift
// OpenAppLock
//
import SwiftUI
/// Standalone app-list management (Settings Manage App Lists): the same
/// create / edit / delete flow the rule editor's picker uses, minus selection.
/// Pushed inside the Settings tab's navigation stack.
struct ManageAppListsView: View {
var body: some View {
AppListLibraryView()
.navigationTitle("App Lists")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@@ -1,266 +0,0 @@
//
// AppsHomeView.swift
// OpenAppLock
//
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, usage: enforcer.usage(for: 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)
usageSection(now: now)
rulesSection(now: now)
}
}
/// Status with the day's usage folded in, so limit rules whose budget is
/// spent count as actively blocking.
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
}
// MARK: - Blocked Apps
@ViewBuilder
private func blockedSection(now: Date) -> some View {
let blocking = rules.filter { liveStatus(for: $0, now: 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, usage: enforcer.usage(for: rule, at: now), 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: - Usage
/// Live tracking for every limit rule scheduled today: how much of the
/// daily budget is spent and what remains.
@ViewBuilder
private func usageSection(now: Date) -> some View {
let tracked = rules.filter {
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
}
if !tracked.isEmpty {
Section {
ForEach(tracked) { rule in
usageRow(for: rule, now: now)
}
} header: {
Text("Usage").textCase(nil)
}
}
}
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let isPaused =
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
return HStack {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(UsageDisplay.subtitle(for: rule, usage: usage))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
.font(.subheadline)
.foregroundStyle(
rule.limitReached(given: usage) && !isPaused
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("usageRow-\(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 = liveStatus(for: rule, now: 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 {
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
}
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
// Shared with the detail sheet: limit rules that aren't blocking show
// their daily budget; everything else uses the live status label.
rule.statusLabel(for: status, relativeTo: now)
}
// 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.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
+ "\($0.appList?.selectionCount ?? 0)|"
+ "\($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<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -0,0 +1,165 @@
//
// HomeView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// The Home tab: what's blocking right now, and live usage for today's limit
/// rules. The rule list and rule creation live on the Rules tab.
struct HomeView: View {
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
@State private var unblockCandidate: BlockingRule?
@State private var hardModeBlockedAttempt = false
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
homeList(now: timeline.date)
}
.navigationTitle("Home")
}
.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, usage: enforcer.usage(for: rule))
enforcer.refresh(rules: rules)
}
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.")
}
}
private func homeList(now: Date) -> some View {
List {
blockingSection(now: now)
usageSection(now: now)
}
}
/// Status with the day's usage folded in, so limit rules whose budget is
/// spent count as actively blocking.
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
}
// MARK: - Currently Blocking
@ViewBuilder
private func blockingSection(now: Date) -> some View {
let blocking = rules.filter { liveStatus(for: $0, now: now).isActive }
Section {
if blocking.isEmpty {
Text("Nothing is blocking right now.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
ForEach(blocking) { rule in
blockingRow(for: rule, now: now)
}
}
} header: {
Text("Currently Blocking").textCase(nil)
}
}
/// A blocking rule: no leading icon. A limit rule shows its type + usage so
/// the kind reads without an icon; a schedule rule shows just its name.
/// Trailing affordance: a lock when Hard Mode (the block can't be lifted),
/// otherwise an Unblock button.
private func blockingRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
return Button {
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
unblockCandidate = rule
} else {
hardModeBlockedAttempt = true
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
if rule.kind != .schedule {
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
.font(.caption)
.foregroundStyle(Color.secondary)
}
}
Spacer()
if rule.hardMode {
Image(systemName: "lock.fill")
.foregroundStyle(.red)
} else {
Text("Unblock")
.foregroundStyle(.tint)
}
}
}
.accessibilityIdentifier("blockedTile-\(rule.name)")
}
// MARK: - Usage
/// Live tracking for every limit rule scheduled today that is *not* already
/// blocking. Once a budget is spent (the rule is actively blocking) the row
/// moves up to "Currently Blocking"; a soft-unblocked rule (paused) stays
/// here reading "Unblocked until tomorrow".
@ViewBuilder
private func usageSection(now: Date) -> some View {
let tracked = rules.filter {
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
&& !liveStatus(for: $0, now: now).isActive
}
if !tracked.isEmpty {
Section {
ForEach(tracked) { rule in
usageRow(for: rule, now: now)
}
} header: {
Text("Usage").textCase(nil)
}
}
}
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
let isPaused =
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
return HStack {
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
.font(.subheadline)
.foregroundStyle(
rule.limitReached(given: usage) && !isPaused
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("usageRow-\(rule.name)")
}
}

View File

@@ -0,0 +1,68 @@
//
// MainTabView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// The post-onboarding shell: a three-tab layout (Home / Rules / Settings).
///
/// The app-level enforcement lifecycle lives here, not on any one tab, so it
/// runs regardless of the selected tab: a 30 s `refresh` loop, a reconcile on
/// any blocking-relevant rule change, and a reconcile whenever the app becomes
/// active (so Uninstall Protection re-evaluates on every foreground).
struct MainTabView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Environment(\.scenePhase) private var scenePhase
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
var body: some View {
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
RulesListView()
.tabItem { Label("Rules", systemImage: "shield.lefthalf.filled") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
}
.task {
await enforcementLoop()
}
.onChange(of: ruleChangeToken) {
refreshEnforcement()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshEnforcement() }
}
}
// 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.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
+ "\($0.appList?.selectionCount ?? 0)|"
+ "\($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<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -15,7 +15,7 @@ struct RootView: View {
var body: some View {
Group {
if hasCompletedOnboarding {
AppsHomeView()
MainTabView()
} else {
OnboardingView {
hasCompletedOnboarding = true

View File

@@ -0,0 +1,109 @@
//
// RulesListView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// The Rules tab: every rule, grouped into Schedule / Time Limit / Open Limit
/// sections. "+" creates a new rule; tapping a row opens its detail sheet.
struct RulesListView: View {
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
@State private var detailRule: BlockingRule?
@State private var showingNewRule = false
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
rulesList(now: timeline.date)
}
.navigationTitle("Rules")
.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()
}
}
@ViewBuilder
private func rulesList(now: Date) -> some View {
List {
if rules.isEmpty {
Section {
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 {
kindSection(.schedule, now: now)
kindSection(.timeLimit, now: now)
kindSection(.openLimit, now: now)
}
}
}
/// One section per rule kind; empty kinds are omitted entirely.
@ViewBuilder
private func kindSection(_ kind: RuleKind, now: Date) -> some View {
let kindRules = rules.filter { $0.kind == kind }
if !kindRules.isEmpty {
Section {
ForEach(kindRules) { rule in
ruleRow(for: rule, now: now)
}
} header: {
Text(kind.displayName).textCase(nil)
}
}
}
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
let status = rule.status(at: now, usage: enforcer.usage(for: rule, 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(rule.statusLabel(for: status, relativeTo: now))
.font(.subheadline)
.foregroundStyle(status.isActive ? .green : .secondary)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
private func blockSummary(for rule: BlockingRule) -> String {
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
}
}

View File

@@ -0,0 +1,63 @@
//
// SettingsView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// The Settings tab: device-level protection and app-list management.
struct SettingsView: View {
@Environment(RuleEnforcer.self) private var enforcer
@Environment(AppSettingsStore.self) private var settings
@Query private var rules: [BlockingRule]
/// Local mirror of the persisted setting; the explicit binding below writes
/// it through to the store and re-enforces in one step.
@State private var uninstallProtectionOn = false
var body: some View {
NavigationStack {
List {
Section {
Toggle("Uninstall Protection", isOn: uninstallProtectionBinding)
.accessibilityIdentifier("uninstallProtectionToggle")
} header: {
Text("Protection").textCase(nil)
} footer: {
Text(
"While on, apps can't be deleted from this device whenever a "
+ "Hard Mode rule is actively blocking — so the block can't be "
+ "removed by uninstalling.")
}
Section {
NavigationLink {
ManageAppListsView()
} label: {
Label("Manage App Lists", systemImage: "square.stack.3d.up")
}
.accessibilityIdentifier("manageAppListsButton")
} header: {
Text("App Lists").textCase(nil)
}
}
.navigationTitle("Settings")
}
.onAppear {
uninstallProtectionOn = settings.uninstallProtectionEnabled
}
}
/// Drives the toggle's visual state from `@State` while persisting and
/// re-enforcing on every change so protection engages/lifts immediately.
private var uninstallProtectionBinding: Binding<Bool> {
Binding(
get: { uninstallProtectionOn },
set: { newValue in
uninstallProtectionOn = newValue
settings.uninstallProtectionEnabled = newValue
enforcer.refresh(rules: rules)
}
)
}
}

View File

@@ -269,3 +269,71 @@ struct OverlappingRuleEnforcementTests {
#expect(shields.shieldedRuleIDs == [rule.id])
}
}
/// Uninstall Protection: `refresh` denies device app removal only while the
/// user opted in *and* a Hard Mode rule is actively blocking.
@MainActor
@Suite("Uninstall protection enforcement")
struct UninstallProtectionEnforcementTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:0017:00
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it
private func hardRule() -> BlockingRule {
BlockingRule(name: "Locked In", hardMode: true)
}
@Test("Disabled setting never denies app removal")
func disabledSettingNeverDenies() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: false))
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
#expect(!shields.appRemovalDenied)
}
@Test("Enabled setting denies removal while a hard rule is blocking")
func deniesDuringHardBlock() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
#expect(shields.appRemovalDenied)
}
@Test("A soft rule does not deny removal even with the setting on")
func softRuleDoesNotDeny() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc)
#expect(!shields.appRemovalDenied)
}
@Test("Denial lifts once the hard window ends")
func liftsWhenWindowEnds() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
let rule = hardRule()
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appRemovalDenied)
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
#expect(!shields.appRemovalDenied)
}
@Test("clearShields(except:) does not disturb the app-removal denial")
func clearShieldsPreservesDenial() {
let shields = MockShieldController()
shields.setAppRemovalDenied(true)
shields.clearShields(except: [])
#expect(shields.appRemovalDenied)
}
}

View File

@@ -79,3 +79,60 @@ struct RulePolicyTests {
#expect(rule.pausedUntil == nil)
}
}
@MainActor
@Suite("Uninstall protection policy")
struct UninstallProtectionPolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
func scheduleRule(hardMode: Bool) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
func hardLimitRule() -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
hardMode: true,
days: Weekday.everyDay)
}
@Test("App removal is only denied with the setting on AND a hard rule active")
func deniedOnlyWhenEnabledAndHardLocked() {
let hard = scheduleRule(hardMode: true)
// Setting off: never deny, even with an active hard rule.
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: false, at: mondayDuringWork, calendar: utc))
// Setting on + active hard rule: deny.
#expect(
RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: true, at: mondayDuringWork, calendar: utc))
// Setting on but the hard rule is outside its window: do not deny.
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [hard], enabled: true, at: mondayEvening, calendar: utc))
}
@Test("A soft rule never triggers app-removal denial")
func softRuleNeverDenies() {
let soft = scheduleRule(hardMode: false)
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [soft], enabled: true, at: mondayDuringWork, calendar: utc))
}
@Test("A spent hard-mode limit rule triggers denial; unspent does not")
func spentHardLimitDenies() {
let rule = hardLimitRule()
#expect(
RulePolicy.shouldDenyAppRemoval(
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 45) },
at: mondayDuringWork, calendar: utc))
#expect(
!RulePolicy.shouldDenyAppRemoval(
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) },
at: mondayDuringWork, calendar: utc))
}
}

View File

@@ -254,4 +254,14 @@ struct UsageDisplayTests {
let over = RuleUsage(minutesUsed: 60)
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
}
@Test("Typed subtitles prefix the rule kind so type is clear without the icon")
func typedSubtitles() {
#expect(
UsageDisplay.typedSubtitle(for: timeRule, usage: RuleUsage(minutesUsed: 18))
== "Time Limit · 18m of 45m used today")
#expect(
UsageDisplay.typedSubtitle(for: openRule, usage: RuleUsage(opensUsed: 2))
== "Open Limit · 2 of 5 opens today")
}
}

View File

@@ -6,7 +6,8 @@
import XCTest
/// App lists: the editor's App List row, the picker (select / create / edit),
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown.
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown. The rule
/// editor (and its app-list picker) is reached from the Rules tab.
final class AppListUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
@@ -14,6 +15,7 @@ final class AppListUITests: XCTestCase {
func testScheduleEditorOffersModeChoice() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -25,6 +27,7 @@ final class AppListUITests: XCTestCase {
func testLimitEditorsAreBlockOnly() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -46,6 +49,7 @@ final class AppListUITests: XCTestCase {
func testCreateAppListFlowSelectsNewList() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -79,6 +83,7 @@ final class AppListUITests: XCTestCase {
func testDetailShowsAppListName() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Block").waitToAppear()
@@ -87,6 +92,7 @@ final class AppListUITests: XCTestCase {
func testHardModeSessionLocksAppListEditing() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
app.goToRulesTab()
// "Sleep" is soft and editable even while "Locked In" hard-blocks.
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
@@ -103,6 +109,7 @@ final class AppListUITests: XCTestCase {
func testAppListsEditableWithoutHardSession() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()

View File

@@ -17,15 +17,17 @@ final class OnboardingUITests: XCTestCase {
app.staticTexts["OpenAppLock"].waitToAppear()
app.buttons["onboardingContinueButton"].waitToAppear().tap()
// Permission step: granting (mocked) lands on the Apps home screen.
// Permission step: granting (mocked) lands on the tabbed home screen.
app.buttons["allowScreenTimeButton"].waitToAppear().tap()
app.buttons["newRuleButton"].waitToAppear()
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists)
app.tabBars.buttons["Home"].waitToAppear()
XCTAssertTrue(app.tabBars.buttons["Rules"].exists)
XCTAssertTrue(app.tabBars.buttons["Settings"].exists)
XCTAssertTrue(app.staticTexts["Currently Blocking"].exists)
}
func testCompletedOnboardingIsSkipped() throws {
let app = XCUIApplication.launchOpenAppLock(onboardingCompleted: true)
app.buttons["newRuleButton"].waitToAppear()
app.tabBars.buttons["Home"].waitToAppear()
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
}
}

View File

@@ -12,6 +12,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateScheduleRuleFromTypeCard() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.element("emptyRulesCard").waitToAppear()
app.buttons["newRuleButton"].tap()
@@ -33,6 +34,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateRuleFromPreset() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["preset-work-time"].waitToAppear().tap()
@@ -44,6 +46,7 @@ final class RuleCreationUITests: XCTestCase {
func testRenameRuleInEditor() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -63,6 +66,7 @@ final class RuleCreationUITests: XCTestCase {
func testDayTogglesFillRowAndHaveLargeTapTargets() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -81,6 +85,7 @@ final class RuleCreationUITests: XCTestCase {
func testDayTogglesUpdateSummary() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -93,6 +98,7 @@ final class RuleCreationUITests: XCTestCase {
func testCreateTimeLimitRule() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -109,6 +115,7 @@ final class RuleCreationUITests: XCTestCase {
func testAdultContentToggleFlowsToDetail() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
@@ -126,6 +133,7 @@ final class RuleCreationUITests: XCTestCase {
func testAdultContentDefaultsToAllowed() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear()
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
@@ -135,6 +143,7 @@ final class RuleCreationUITests: XCTestCase {
/// not offer the toggle, and the rule's detail must not show the row.
func testTimeLimitOmitsAdultContent() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
@@ -159,6 +168,7 @@ final class RuleCreationUITests: XCTestCase {
func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.staticTexts["ruleEditorTitle"].waitToAppear()
@@ -174,6 +184,7 @@ final class RuleCreationUITests: XCTestCase {
func testNewRuleSheetShowsTypesAndPresets() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToRulesTab()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear()

View File

@@ -7,6 +7,7 @@ import XCTest
/// Detail sheet, editing, disabling, deleting, and unblocking seeded with
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep").
/// Rule cards live on the Rules tab; blocked tiles on the Home tab.
final class RuleManagementUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
@@ -14,6 +15,7 @@ final class RuleManagementUITests: XCTestCase {
func testDetailShowsLiveStatusAndFacts() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
@@ -30,6 +32,7 @@ final class RuleManagementUITests: XCTestCase {
func testEditRuleTogglesHardModeOn() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
@@ -44,6 +47,7 @@ final class RuleManagementUITests: XCTestCase {
func testDisableRule() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
@@ -65,6 +69,7 @@ final class RuleManagementUITests: XCTestCase {
func testDeleteRuleRemovesCard() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
app.goToRulesTab()
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
@@ -84,11 +89,13 @@ final class RuleManagementUITests: XCTestCase {
func testUnblockActiveSoftRule() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
// The active rule surfaces in Blocked Apps; unblocking pauses it.
// The active rule surfaces in Currently Blocking (Home tab); unblocking pauses it.
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap()
app.staticTexts["nothingBlockedLabel"].waitToAppear()
// The paused state shows on the rule's card over on the Rules tab.
app.goToRulesTab()
XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
}
}
@@ -101,6 +108,7 @@ final class HardModeUITests: XCTestCase {
func testHardLockedRuleCannotBeEdited() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
app.goToRulesTab()
app.buttons["ruleCard-Locked In"].waitToAppear().tap()
@@ -113,6 +121,8 @@ final class HardModeUITests: XCTestCase {
func testHardLockedRuleCannotBeUnblocked() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
// The hard rule shows a lock (not an Unblock button) in Currently Blocking;
// tapping the row still explains why it can't be lifted.
app.buttons["blockedTile-Locked In"].waitToAppear().tap()
// No unblock dialog just the refusal alert.

View File

@@ -0,0 +1,67 @@
//
// SettingsUITests.swift
// OpenAppLockUITests
//
import XCTest
/// The Settings tab: the Uninstall Protection toggle and the Manage App Lists
/// flow (which reuses the rule editor's app-list library, minus selection).
final class SettingsUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testUninstallProtectionToggleStartsOffAndFlips() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToSettingsTab()
let toggle = app.switches["uninstallProtectionToggle"].waitToAppear()
XCTAssertEqual(toggle.value as? String, "0", "Uninstall Protection should default off")
// `.tap()` lands on the element's center (over the label) and doesn't
// reliably flip a SwiftUI switch tap the control itself.
toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()
XCTAssertEqual(toggle.value as? String, "1", "Tapping should turn it on")
}
func testManageAppListsCreateFlow() throws {
let app = XCUIApplication.launchOpenAppLock()
app.goToSettingsTab()
app.element("manageAppListsButton").waitToAppear().tap()
// Fresh install: no lists yet the same create flow as the rule editor.
app.element("emptyAppListsLabel").waitToAppear()
app.buttons["newAppListButton"].tap()
let nameField = app.textFields["appListNameField"].waitToAppear()
nameField.tap()
nameField.typeText("Distractions\n")
app.element("emptySelectionLabel").waitToAppear()
app.buttons["editAppsButton"].tap()
app.element("selectionCountLabel").waitToAppear()
app.buttons["confirmSelectionButton"].tap()
app.buttons["saveAppListButton"].waitToAppear().tap()
// Saving returns to the management list with the new list present.
app.element("appListRow-Distractions").waitToAppear()
}
func testManageAppListsLockedDuringHardSession() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
app.goToSettingsTab()
app.element("manageAppListsButton").waitToAppear().tap()
// The seeded "Distractions" list is visible but read-only while the
// hard-mode rule is blocking same lock as the rule editor's picker.
app.element("appListRow-Distractions").waitToAppear()
app.element("appListsLockedNotice").waitToAppear()
XCTAssertFalse(
app.buttons["editAppListButton-Distractions"].exists,
"App lists must be read-only while a Hard Mode rule is blocking"
)
}
}

View File

@@ -32,6 +32,16 @@ extension XCUIApplication {
}
}
extension XCUIApplication {
/// Switches to the Home tab (Currently Blocking + Usage). Home is the
/// default selection, so most Home-tab tests don't need to call this.
func goToHomeTab() { tabBars.buttons["Home"].waitToAppear().tap() }
/// Switches to the Rules tab (the rule list + New Rule button).
func goToRulesTab() { tabBars.buttons["Rules"].waitToAppear().tap() }
/// Switches to the Settings tab (Uninstall Protection + Manage App Lists).
func goToSettingsTab() { tabBars.buttons["Settings"].waitToAppear().tap() }
}
extension XCUIElement {
/// Asserts the element appears within the timeout, then returns it.
@discardableResult

View File

@@ -5,35 +5,45 @@
import XCTest
/// The Usage section under Blocked Apps seeded with limit rules at various
/// The Usage section on the Home tab seeded with limit rules at various
/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens,
/// "Doom Scroll" spent blocked).
/// "Doom Scroll" spent moved to Currently Blocking).
final class UsageUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testUsageSectionShowsTimeAndOpenBudgets() throws {
func testUsageSectionShowsTypeAndBudgets() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists)
// The row leads with the rule type, then the live usage and remaining.
let timeRow = app.element("usageRow-Time Keeper").waitToAppear()
XCTAssertTrue(timeRow.label.contains("Time Limit"), "Got: \(timeRow.label)")
XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)")
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
let openRow = app.element("usageRow-Gate Keeper").waitToAppear()
XCTAssertTrue(openRow.label.contains("Open Limit"), "Got: \(openRow.label)")
XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)")
XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)")
}
func testSpentBudgetShowsAsBlockedUntilTomorrow() throws {
func testSpentBudgetMovesToCurrentlyBlocking() throws {
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear()
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)")
// A spent budget is a real block: the rule surfaces in Blocked Apps.
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists)
// A spent budget is a real block: the rule moves out of Usage and into
// Currently Blocking, carrying its type + usage tracking.
let tile = app.buttons["blockedTile-Doom Scroll"].waitToAppear()
XCTAssertTrue(tile.label.contains("Time Limit"), "Got: \(tile.label)")
XCTAssertTrue(tile.label.contains("30m of 30m used today"), "Got: \(tile.label)")
// It is no longer tracked under Usage.
XCTAssertFalse(
app.element("usageRow-Doom Scroll").exists,
"A spent rule should leave the Usage section for Currently Blocking"
)
}
func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
@@ -42,6 +52,7 @@ final class UsageUITests: XCTestCase {
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap()
// Unblocked paused (not blocking), so it drops back into Usage.
app.staticTexts["nothingBlockedLabel"].waitToAppear()
let row = app.element("usageRow-Doom Scroll").waitToAppear()
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")

View File

@@ -19,6 +19,10 @@ protocol ShieldApplying: AnyObject {
/// 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>)
/// Engages or relinquishes the device-wide app-removal denial used by
/// Uninstall Protection. Independent of per-rule shields: it lives on its
/// own store so clearing rule shields never disturbs it.
func setAppRemovalDenied(_ denied: Bool)
}
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
@@ -65,6 +69,17 @@ final class ManagedSettingsShieldController: ShieldApplying {
}
}
func setAppRemovalDenied(_ denied: Bool) {
// A dedicated store keeps this device-wide setting off the per-rule
// stores, which get fully cleared when their shield lifts. Setting nil
// (not false) relinquishes the constraint entirely when off.
let store = ManagedSettingsStore(named: Self.uninstallProtectionStoreName)
store.application.denyAppRemoval = denied ? true : nil
}
private static let uninstallProtectionStoreName =
ManagedSettingsStore.Name("uninstall-protection")
private func store(for ruleID: UUID) -> ManagedSettingsStore {
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
}
@@ -91,6 +106,10 @@ final class MockShieldController: ShieldApplying {
private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
private(set) var appliedSelectionData: [UUID: Data?] = [:]
/// The device-wide app-removal denial. Deliberately separate from the
/// per-rule shield bookkeeping, mirroring the dedicated store in the real
/// controller `clearShields(except:)` must not disturb it.
private(set) var appRemovalDenied = false
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
@@ -115,6 +134,11 @@ final class MockShieldController: ShieldApplying {
activeRuleIDs.contains($0.key)
}
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
// Note: appRemovalDenied is intentionally left untouched here.
}
func setAppRemovalDenied(_ denied: Bool) {
appRemovalDenied = denied
}
}

View File

@@ -91,14 +91,16 @@ Dark theme throughout (near-black background, very dark green tint).
*(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day
also appear here, blocked until midnight.
3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section
directly below Blocked Apps showing live tracking for every enabled
Time/Open Limit rule scheduled today:
- Time Limit row: subtitle "18m of 45m used today", trailing "27m left";
when spent: "Blocked until tomorrow" (red).
- Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left";
when spent: "Blocked until tomorrow" (red).
Usage numbers come from the shared app-group ledger written by the
DeviceActivity monitor and shield-action extensions.
showing live tracking for every enabled Time/Open Limit rule scheduled today
**that is not currently blocking**. Each row leads its subtitle with the rule
**type** so the kind is clear without relying on the icon:
- Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left".
- Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left".
A rule whose budget is **spent** (actively blocking) **moves out of Usage into
the "Currently Blocking" section** (it shows its type + usage there instead);
a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage
reading "Unblocked until tomorrow". Usage numbers come from the shared app-group
ledger written by the DeviceActivity monitor and shield-action extensions.
3. **Rules** — header row: "Rules " (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet.
@@ -514,11 +516,28 @@ Xh", "Xh left") is **derived**, not stored.
OpenAppLock has since replaced the Opal-style custom presentation with the bare
iOS design language, keeping the backend (models, logic, services), the
flows, and the accessibility identifiers intact. Sections 15 remain as the
reference for *what* the feature does; presentation now maps as follows:
reference for *what* the feature does; presentation now maps as follows.
After onboarding the app is a three-tab `TabView` (`MainTabView`), each tab its
own `NavigationStack`:
```
TabView: [Home] [Rules] [Settings]
│ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode)
│ └── rules grouped into Schedule / Time Limit / Open Limit sections; "+" ─▶ New Rule sheet
│ └── tap a rule row ─▶ Rule Detail sheet ─▶ "Edit Rule" ─▶ Rule Editor
└── "Currently Blocking" section + "Usage" section
```
The app-level **enforcement lifecycle** (the `enforcer.refresh` 30 s loop, the
rule-change reconcile, and a scene-active reconcile) lives on `MainTabView`, so
it runs regardless of the selected tab.
| Spec element | Native presentation |
|---|---|
| Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button |
| Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a `<Type> · <usage>` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a `<Type> · NN of MM used today` subtitle + trailing remaining/blocked label. |
| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. |
| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). |
| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead |
| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` |
| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule |
@@ -526,3 +545,22 @@ reference for *what* the feature does; presentation now maps as follows:
Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
icon-pair/circle-button chrome.
### 6.1 Uninstall Protection (Settings)
A device-wide opt-in that makes Hard Mode harder to escape: while it is on **and**
any Hard Mode rule is actively blocking, the user cannot delete apps from the
device. `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` (= setting on
AND any rule `isHardLocked`) is the single gate; `RuleEnforcer.refresh` applies it
through `ShieldApplying.setAppRemovalDenied`, which sets
`ManagedSettingsStore(named: "uninstall-protection").application.denyAppRemoval`
(`true` to engage, `nil` to relinquish) on a **dedicated** store so per-rule
shield clears never touch it. The setting persists in the app-group defaults
(`uninstallProtectionEnabled`).
Enforced on the **foreground path only** for v1 (launch + 30 s loop + rule change
+ scene-active). Known limitation: a Hard Mode window that *ends* while the app is
closed leaves protection engaged until the app is next foregrounded — the safe
failure direction for a locker. Background recompute in the monitor extension is a
follow-up. Like all Screen Time behavior, the real device effect is only
observable on a device (the simulator uses mock shields).