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).
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,
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user