diff --git a/AGENTS.md b/AGENTS.md index bfdfe19..c0456b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/OpenAppLock/Views/AppLists/AppListEditorView.swift b/OpenAppLock/Views/AppLists/AppListEditorView.swift index cd79e3c..d33e70b 100644 --- a/OpenAppLock/Views/AppLists/AppListEditorView.swift +++ b/OpenAppLock/Views/AppLists/AppListEditorView.swift @@ -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) { + 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() + } +} diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift index 0468a7f..018b577 100644 --- a/OpenAppLockUITests/AppListUITests.swift +++ b/OpenAppLockUITests/AppListUITests.swift @@ -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. diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 7b88e65..7630e33 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -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.