Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@ on:

jobs:
test:
name: Run all tests on iOS
name: Test on ${{ matrix.device }}
runs-on: macos-26
timeout-minutes: 30

strategy:
# Run every device leg to completion: an iPad failure shouldn't hide the
# iPhone result (or vice versa).
fail-fast: false
matrix:
include:
- device: iPhone 17 Pro
slug: iphone
- device: iPad Pro 11-inch (M5)
slug: ipad

steps:
- name: Checkout current repository
uses: actions/checkout@v4
Expand All @@ -24,7 +35,7 @@ jobs:
xcodebuild build-for-testing \
-project OpenAppLock.xcodeproj \
-scheme OpenAppLock \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
-xcconfig Config/CI.xcconfig

- name: Run tests
Expand All @@ -34,7 +45,7 @@ jobs:
xcodebuild test-without-building \
-project OpenAppLock.xcodeproj \
-scheme OpenAppLock \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
-resultBundlePath ./output/test-result.xcresult \
-xcconfig Config/CI.xcconfig

Expand All @@ -45,11 +56,11 @@ jobs:
- name: Upload test result
uses: actions/upload-artifact@v4
with:
name: test-result
name: test-result-${{ matrix.slug }}
path: output/test-result.zip

- name: Check for test failure
if: steps.tests.outcome == 'failure'
run: |
echo "Tests failed. Check the uploaded artifacts for details."
echo "Tests failed on ${{ matrix.device }}. Check the uploaded artifacts for details."
exit 1
15 changes: 12 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ off-limits to agent edits.
- Shields: one `ManagedSettingsStore` per rule (`rule-<uuid>`), tracked in
UserDefaults for stray cleanup. `blockAdultContent` engages
`webContent.blockedByFilter = .auto()` alongside the shield.
- `RuleEnforcer.refresh` is the only place shields change; the home view runs
it on rule changes and a 30s loop while visible.
- `RuleEnforcer.refresh` is the only place shields change; the post-onboarding
shell (`MainView`) runs it on rule changes and a 30s loop while the app is open,
regardless of the active layout (compact `TabView` vs regular-width sidebar).

## Build & test

Expand Down Expand Up @@ -156,7 +157,8 @@ them): `newRuleButton`, `ruleCard-<name>`, `ruleStatus-<name>`,
`maxOpensStepper(+Value)`, `commitRuleButton`, `doneButton`,
`toggleEnabledButton`, `deleteRuleButton`, `closeDetailButton`,
`detailRuleName`, `detailStatusLabel`, `detailRow-<label>`,
`hardModeLockedNotice`, settings About links: `githubLinkButton` /
`hardModeLockedNotice`, `sidebarItem-<section>` (iPad sidebar rows: `home` /
`rules` / `settings`), settings About links: `githubLinkButton` /
`websiteLinkButton`, onboarding: `onboardingContinueButton`,
`allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`.

Expand All @@ -179,6 +181,13 @@ Gotchas learned the hard way:
`Color.primary`/`Color.secondary`/`Color(.tertiaryLabel)`.
- The unblock confirmation dialog is queried via `app.sheets.buttons[...]`
(a bare `buttons["Unblock"]` is ambiguous with the row label).
- **iPad presentation differs and the UI suite runs on both** (CI matrix:
iPhone + iPad). On iPad the shell is a sidebar, not a tab bar — navigate with
the idiom-aware `goToHomeTab()/…` and `waitForMainUI()` helpers, never bare
`tabBars`. Sheets present as *centered, shorter* form sheets, so: a window-edge
swipe-back misses them (use the nav `BackButton`); rows can start below the
fold (scroll into view); and span/width assertions measured against the full
window only hold on iPhone (gate with `UIDevice.current.userInterfaceIdiom`).

## Known gaps / next steps

Expand Down
24 changes: 18 additions & 6 deletions Docs/AGENT_RULES_FEATURE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,20 +523,32 @@ iOS design language, keeping the backend (models, logic, services), the
flows, and the accessibility identifiers intact. Sections 1–5 remain as the
spec 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`:
After onboarding the app is an **adaptive shell** (`MainView`) holding the same
three sections (Home / Rules / Settings), each its own `NavigationStack`. The
navigation chrome is chosen from the horizontal size class
(`MainLayout.resolve`), reusing identical section views in both layouts:

- **Compact width** (iPhone, and iPad multitasking / Slide Over): a bottom
`TabView` (`MainTabView`).
- **Regular width** (full-screen iPad): a left sidebar `NavigationSplitView`
(`MainSidebarView`) — sections listed in the sidebar (`sidebarItem-<section>`),
the selected one filling the detail column. This is the iPad-idiomatic
presentation; a bottom tab bar is an iPhone idiom (Apple HIG).

```
TabView: [Home] [Rules] [Settings]
MainView (adaptive): compact ─▶ TabView [Home] [Rules] [Settings] (bottom tabs)
regular ─▶ NavigationSplitView sidebar │ detail (left sidebar)
│ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode) + "About" GitHub / Website links
│ └── 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.
Section labels and icons come from one source of truth, the `AppSection` enum,
so the tab bar and the sidebar can't drift. The app-level **enforcement
lifecycle** (the `enforcer.refresh` 30 s loop, the rule-change reconcile, and a
scene-active reconcile) lives on `MainView`, so it runs regardless of the active
layout or selected section.

| Spec element | Native presentation |
|---|---|
Expand Down
51 changes: 51 additions & 0 deletions OpenAppLock/Views/MainSidebarView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// MainSidebarView.swift
// OpenAppLock
//

import SwiftUI

/// The regular-width (full-screen iPad) layout: a persistent left sidebar listing
/// the top-level sections, with the selected section filling the detail column.
///
/// Reuses the exact same section views as the compact `TabView` (`HomeView`,
/// `RulesListView`, `SettingsView` — each brings its own `NavigationStack`); only
/// the navigation chrome differs. Sidebar rows are built from `AppSection`, the
/// shared source of truth, so labels and icons match the tab bar.
struct MainSidebarView: View {
@State private var selection: AppSection? = .home

var body: some View {
// Pin the sidebar visible: on the roomy iPad canvas the top-level
// sections should always be in reach (HIG), and a constant column
// visibility keeps the layout stable for UI tests and rotation.
NavigationSplitView(columnVisibility: .constant(.all)) {
List(selection: $selection) {
ForEach(AppSection.allCases) { section in
Label(section.title, systemImage: section.systemImage)
// Collapse the icon + title into one queryable, hittable
// element so UI tests target the row, not the bare symbol.
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebarItem-\(section.rawValue)")
.tag(section)
}
}
.navigationTitle("OpenAppLock")
} detail: {
detail(for: selection ?? .home)
}
.navigationSplitViewStyle(.balanced)
}

@ViewBuilder
private func detail(for section: AppSection) -> some View {
switch section {
case .home:
HomeView()
case .rules:
RulesListView()
case .settings:
SettingsView()
}
}
}
60 changes: 9 additions & 51 deletions OpenAppLock/Views/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,66 +3,24 @@
// OpenAppLock
//

import SwiftData
import SwiftUI

/// The post-onboarding shell: a three-tab layout (Home / Rules / Settings).
/// The compact-width layout: the three top-level sections as a bottom `TabView`.
/// Used on iPhone and in iPad multitasking / Slide Over (anywhere the horizontal
/// size class is compact). The app-level enforcement lifecycle lives on the
/// adaptive shell `MainView`, not here, so it runs regardless of layout.
///
/// 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).
/// Tab labels and icons come from `AppSection`, the same source the regular-width
/// sidebar (`MainSidebarView`) uses, so the two layouts can never drift.
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") }
.tabItem { Label(AppSection.home.title, systemImage: AppSection.home.systemImage) }
RulesListView()
.tabItem { Label("Rules", systemImage: "shield.lefthalf.filled") }
.tabItem { Label(AppSection.rules.title, systemImage: AppSection.rules.systemImage) }
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))
.tabItem { Label(AppSection.settings.title, systemImage: AppSection.settings.systemImage) }
}
}
}
75 changes: 75 additions & 0 deletions OpenAppLock/Views/MainView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// MainView.swift
// OpenAppLock
//

import SwiftData
import SwiftUI

/// The post-onboarding shell. Chooses its navigation chrome from the horizontal
/// size class — a bottom `TabView` (`MainTabView`) in compact width (iPhone, iPad
/// multitasking) and a left sidebar (`MainSidebarView`) in regular width
/// (full-screen iPad) — and owns the app-level enforcement lifecycle so it runs
/// regardless of which layout is showing and which section is selected.
///
/// The lifecycle is 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 MainView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]

var body: some View {
layout
.task {
await enforcementLoop()
}
.onChange(of: ruleChangeToken) {
refreshEnforcement()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshEnforcement() }
}
}

@ViewBuilder
private var layout: some View {
switch MainLayout.resolve(horizontalSizeClass: horizontalSizeClass) {
case .tabs:
MainTabView()
case .sidebar:
MainSidebarView()
}
}

// 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))
}
}
}
35 changes: 35 additions & 0 deletions OpenAppLock/Views/Navigation/AppSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// AppSection.swift
// OpenAppLock
//

import Foundation

/// The app's top-level sections. Single source of truth for the post-onboarding
/// navigation, shared by the compact `TabView` (`MainTabView`) and the
/// regular-width sidebar (`MainSidebarView`) so the two layouts can't drift.
enum AppSection: String, CaseIterable, Identifiable {
case home
case rules
case settings

var id: String { rawValue }

/// User-facing label shown in the tab item and the sidebar row.
var title: String {
switch self {
case .home: "Home"
case .rules: "Rules"
case .settings: "Settings"
}
}

/// SF Symbol shown alongside the title in both layouts.
var systemImage: String {
switch self {
case .home: "house"
case .rules: "shield.lefthalf.filled"
case .settings: "gearshape"
}
}
}
22 changes: 22 additions & 0 deletions OpenAppLock/Views/Navigation/MainLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// MainLayout.swift
// OpenAppLock
//

import SwiftUI

/// Which navigation chrome the post-onboarding shell shows, chosen from the
/// horizontal size class: a left sidebar on the roomy regular-width iPad canvas,
/// the bottom tab bar everywhere else (iPhone, plus iPad multitasking / Slide
/// Over, where the width is compact).
enum MainLayout {
case tabs
case sidebar

/// Resolves the layout from the current horizontal size class. The sidebar is
/// reserved for *known* regular width; an undetermined (`nil`) size class
/// falls back to the iPhone-safe tab bar.
static func resolve(horizontalSizeClass: UserInterfaceSizeClass?) -> MainLayout {
horizontalSizeClass == .regular ? .sidebar : .tabs
}
}
Loading
Loading