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:
@@ -126,5 +126,12 @@ Gotchas learned the hard way:
|
|||||||
- Time Limit / Open Limit rules are fully modeled, editable, and displayed,
|
- Time Limit / Open Limit rules are fully modeled, editable, and displayed,
|
||||||
but not enforced (see above).
|
but not enforced (see above).
|
||||||
- `FamilyActivityPicker` shows few apps on the simulator; fine on device.
|
- `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
|
- Distribution (App Store) requires Apple's approval for the Family Controls
|
||||||
entitlement; development builds work with the dev entitlement.
|
entitlement; development builds work with the dev entitlement.
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import FamilyControls
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Creates or edits an app list: a name field above Apple's
|
/// Creates or edits an app list. A plain List (consistent with the rest of
|
||||||
/// `FamilyActivityPicker`. Pushed inside the App List picker's stack, so the
|
/// the app) holds the name field and the apps currently in the list; "Edit
|
||||||
/// back swipe cancels and Save commits.
|
/// 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 {
|
struct AppListEditorView: View {
|
||||||
/// Nil creates a new list; otherwise edits (and saves into) the given one.
|
/// Nil creates a new list; otherwise edits (and saves into) the given one.
|
||||||
let list: AppList?
|
let list: AppList?
|
||||||
@@ -19,6 +20,7 @@ struct AppListEditorView: View {
|
|||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var selection: FamilyActivitySelection
|
@State private var selection: FamilyActivitySelection
|
||||||
|
@State private var pickingApps = false
|
||||||
|
|
||||||
init(list: AppList?, onComplete: @escaping (AppList) -> Void) {
|
init(list: AppList?, onComplete: @escaping (AppList) -> Void) {
|
||||||
self.list = list
|
self.list = list
|
||||||
@@ -28,19 +30,36 @@ struct AppListEditorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
List {
|
||||||
// Styled like the app's other grouped inputs (rounded fill, no
|
Section {
|
||||||
// border) — FamilyActivityPicker below brings its own background,
|
TextField("List Name", text: $name)
|
||||||
// so the field sits directly on the sheet background.
|
.submitLabel(.done)
|
||||||
TextField("List Name", text: $name)
|
.accessibilityIdentifier("appListNameField")
|
||||||
.submitLabel(.done)
|
} header: {
|
||||||
.padding(12)
|
Text("Name").textCase(nil)
|
||||||
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12))
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.accessibilityIdentifier("appListNameField")
|
|
||||||
|
|
||||||
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")
|
.navigationTitle(list == nil ? "New List" : "Edit List")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -55,30 +74,29 @@ struct AppListEditorView: View {
|
|||||||
.accessibilityIdentifier("saveAppListButton")
|
.accessibilityIdentifier("saveAppListButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.navigationDestination(isPresented: $pickingApps) {
|
||||||
VStack(spacing: 8) {
|
AppPickerScreen(selection: $selection)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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() {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ final class AppListUITests: XCTestCase {
|
|||||||
let nameField = app.textFields["appListNameField"].waitToAppear()
|
let nameField = app.textFields["appListNameField"].waitToAppear()
|
||||||
nameField.tap()
|
nameField.tap()
|
||||||
nameField.typeText("Focus Apps\n")
|
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()
|
app.buttons["saveAppListButton"].waitToAppear().tap()
|
||||||
|
|
||||||
// Saving pops back to the picker with the new list selected.
|
// Saving pops back to the picker with the new list selected.
|
||||||
|
|||||||
@@ -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
|
icons. Each icon has a lock badge overlay and a teal/green rounded-rect
|
||||||
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
|
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
|
||||||
if hard mode — not demoed).
|
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,
|
3. **Rules** — header row: "Rules ›" (leading, tappable to a full list,
|
||||||
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
|
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
|
||||||
sheet.
|
sheet.
|
||||||
@@ -257,6 +268,15 @@ Full-height sheet:
|
|||||||
4. **Time-limit rules** — accumulate usage daily across the selected apps;
|
4. **Time-limit rules** — accumulate usage daily across the selected apps;
|
||||||
on crossing the threshold, shield until the `Until` reset point
|
on crossing the threshold, shield until the `Until` reset point
|
||||||
(e.g. tomorrow), then reset the budget.
|
(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
|
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
|
||||||
rule (card presumably shows disabled state). No delete flow was shown;
|
rule (card presumably shows disabled state). No delete flow was shown;
|
||||||
add delete via swipe/long-press or a button in the editor.
|
add delete via swipe/long-press or a button in the editor.
|
||||||
|
|||||||
Reference in New Issue
Block a user