refactor: two-screen app-list editor with native List layout

Screen 1 is a plain List (name field, the apps the list contains via
FamilyControls token Labels, and an Edit Apps button); screen 2 pushes
Apple's FamilyActivityPicker whose Save applies the selection back.
Documents that the picker silently drops selections without real
FamilyControls authorization (mocked in -ui-testing launches).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:15:12 -04:00
parent d8d7028d60
commit c7f2979d8e
4 changed files with 148 additions and 35 deletions

View File

@@ -126,5 +126,12 @@ Gotchas learned the hard way:
- Time Limit / Open Limit rules are fully modeled, editable, and displayed,
but not enforced (see above).
- `FamilyActivityPicker` shows few apps on the simulator; fine on device.
- `FamilyActivityPicker` **silently ignores selections** (binding never
updates, rows still show checkmarks) unless real FamilyControls
authorization has been granted — in `-ui-testing` launches authorization
is mocked, so picker selections can never be asserted in UI tests. To
verify selection flows manually on the simulator, launch without
`-ui-testing`, complete onboarding, and approve the system Screen Time
prompts ("Allow with Passcode" works on the simulator).
- Distribution (App Store) requires Apple's approval for the Family Controls
entitlement; development builds work with the dev entitlement.

View File

@@ -7,9 +7,10 @@ import FamilyControls
import SwiftData
import SwiftUI
/// Creates or edits an app list: a name field above Apple's
/// `FamilyActivityPicker`. Pushed inside the App List picker's stack, so the
/// back swipe cancels and Save commits.
/// Creates or edits an app list. A plain List (consistent with the rest of
/// the app) holds the name field and the apps currently in the list; "Edit
/// Apps" pushes Apple's Screen Time picker, whose Save applies the new
/// selection back here. The navigation-bar checkmark persists the list.
struct AppListEditorView: View {
/// Nil creates a new list; otherwise edits (and saves into) the given one.
let list: AppList?
@@ -19,6 +20,7 @@ struct AppListEditorView: View {
@State private var name: String
@State private var selection: FamilyActivitySelection
@State private var pickingApps = false
init(list: AppList?, onComplete: @escaping (AppList) -> Void) {
self.list = list
@@ -28,19 +30,36 @@ struct AppListEditorView: View {
}
var body: some View {
VStack(spacing: 0) {
// Styled like the app's other grouped inputs (rounded fill, no
// border) FamilyActivityPicker below brings its own background,
// so the field sits directly on the sheet background.
TextField("List Name", text: $name)
.submitLabel(.done)
.padding(12)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
.padding(.vertical, 8)
.accessibilityIdentifier("appListNameField")
List {
Section {
TextField("List Name", text: $name)
.submitLabel(.done)
.accessibilityIdentifier("appListNameField")
} header: {
Text("Name").textCase(nil)
}
FamilyActivityPicker(selection: $selection)
Section {
if AppSelectionCodec.count(of: selection) == 0 {
Text("No apps yet. Edit Apps to choose what this list includes.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptySelectionLabel")
} else {
selectionRows
}
Button {
pickingApps = true
} label: {
Label("Edit Apps", systemImage: "checklist")
}
.accessibilityIdentifier("editAppsButton")
} header: {
HStack {
Text("Apps").textCase(nil)
Spacer()
Text(countLabel).textCase(nil)
}
}
}
.navigationTitle(list == nil ? "New List" : "Edit List")
.navigationBarTitleDisplayMode(.inline)
@@ -55,30 +74,29 @@ struct AppListEditorView: View {
.accessibilityIdentifier("saveAppListButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveAppListBottomButton")
}
.padding(.horizontal)
.padding(.bottom, 8)
.navigationDestination(isPresented: $pickingApps) {
AppPickerScreen(selection: $selection)
}
}
private var selectionSummary: String {
/// Rows for everything the selection contains. FamilyControls' Label
/// initializers resolve the opaque tokens to icon + name.
@ViewBuilder
private var selectionRows: some View {
ForEach(Array(selection.applicationTokens), id: \.self) { token in
Label(token)
}
ForEach(Array(selection.categoryTokens), id: \.self) { token in
Label(token)
}
ForEach(Array(selection.webDomainTokens), id: \.self) { token in
Label(token)
}
}
private var countLabel: String {
let count = AppSelectionCodec.count(of: selection)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
return count == 1 ? "1 App" : "\(count) Apps"
}
private func save() {
@@ -99,3 +117,63 @@ struct AppListEditorView: View {
}
}
}
/// Screen 2: Apple's Screen Time picker. Save applies the working selection
/// back to the editor and pops; the back swipe discards it.
private struct AppPickerScreen: View {
@Binding var selection: FamilyActivitySelection
@Environment(\.dismiss) private var dismiss
@State private var working: FamilyActivitySelection
init(selection: Binding<FamilyActivitySelection>) {
self._selection = selection
self._working = State(initialValue: selection.wrappedValue)
}
var body: some View {
FamilyActivityPicker(selection: $working)
.navigationTitle("Edit Apps")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .confirm) {
saveSelection()
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Save Apps")
.accessibilityIdentifier("confirmSelectionButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
saveSelection()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal)
.padding(.bottom, 8)
}
}
private var selectionSummary: String {
let count = AppSelectionCodec.count(of: working)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
}
private func saveSelection() {
selection = working
dismiss()
}
}

View File

@@ -58,6 +58,14 @@ final class AppListUITests: XCTestCase {
let nameField = app.textFields["appListNameField"].waitToAppear()
nameField.tap()
nameField.typeText("Focus Apps\n")
// Screen 1 lists the (empty) selection; Edit Apps pushes the Screen
// Time picker, whose Save returns here.
app.element("emptySelectionLabel").waitToAppear()
app.buttons["editAppsButton"].tap()
app.element("selectionCountLabel").waitToAppear()
app.buttons["confirmSelectionButton"].tap()
app.buttons["saveAppListButton"].waitToAppear().tap()
// Saving pops back to the picker with the new list selected.

View File

@@ -76,6 +76,17 @@ Dark theme throughout (near-black background, very dark green tint).
icons. Each icon has a lock badge overlay and a teal/green rounded-rect
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
if hard mode — not demoed).
*(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.
3. **Rules** — header row: "Rules " (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet.
@@ -257,6 +268,15 @@ Full-height sheet:
4. **Time-limit rules** — accumulate usage daily across the selected apps;
on crossing the threshold, shield until the `Until` reset point
(e.g. tomorrow), then reset the budget.
*(OpenAppLock specifics)*: usage lives in a per-rule, per-day **usage
ledger** in the app group. A limit rule's derived status becomes
`active(until: next midnight)` once the ledger reports the budget spent on
an enabled day — it then surfaces in Blocked Apps, Hard Mode gating
applies, and a soft unblock pauses it until midnight. Open-limit rules
work the same with an opens budget; while opens remain, their apps stay
shielded with an "Open" button on the shield (each press spends one open
and lifts the shield for up to 15 minutes — the DeviceActivity minimum
interval).
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.