diff --git a/ios/ancla-app/app-view-model.swift b/ios/ancla-app/app-view-model.swift index 6dfb656..2c27a78 100644 --- a/ios/ancla-app/app-view-model.swift +++ b/ios/ancla-app/app-view-model.swift @@ -38,6 +38,7 @@ final class AppViewModel { var draftSelection = FamilyActivitySelection() var draftModeName = "Work block" var draftModeShouldBeDefault = false + var draftModeIsStrict = false var draftTagName = "Desk anchor" var selectedModeID: UUID? var isPickerPresented = false @@ -159,6 +160,10 @@ final class AppViewModel { AnclaCore.canArmSelectedMode(snapshot) } + var currentModeIsStrict: Bool { + (selectedMode() ?? preferredMode())?.isStrict == true + } + func load() { do { snapshot = AnclaCore.repairedSnapshot(try store.load()) @@ -204,6 +209,7 @@ final class AppViewModel { clearDefaultFlag() } mode.isDefault = shouldBeDefault + mode.isStrict = draftModeIsStrict snapshot.modes[index] = mode ensureDefaultMode() @@ -218,13 +224,15 @@ final class AppViewModel { mode = BlockMode( name: modeName, selectionData: Data(), - isDefault: snapshot.modes.isEmpty || draftModeShouldBeDefault + isDefault: snapshot.modes.isEmpty || draftModeShouldBeDefault, + isStrict: draftModeIsStrict ) } else { mode = try BlockMode( name: modeName, selection: draftSelection, - isDefault: snapshot.modes.isEmpty || draftModeShouldBeDefault + isDefault: snapshot.modes.isEmpty || draftModeShouldBeDefault, + isStrict: draftModeIsStrict ) } @@ -251,6 +259,7 @@ final class AppViewModel { draftModeName = "Work block" draftSelection = FamilyActivitySelection() draftModeShouldBeDefault = snapshot.modes.isEmpty + draftModeIsStrict = false } func prepareDraftForEditingMode(_ modeID: UUID) { @@ -263,6 +272,7 @@ final class AppViewModel { draftModeID = mode.id draftModeName = mode.name draftModeShouldBeDefault = mode.isDefault + draftModeIsStrict = mode.isStrict if isSideloadLiteBuild && mode.selectionData.isEmpty { draftSelection = FamilyActivitySelection() diff --git a/ios/ancla-app/content-view.swift b/ios/ancla-app/content-view.swift index b5c998b..04619fe 100644 --- a/ios/ancla-app/content-view.swift +++ b/ios/ancla-app/content-view.swift @@ -20,6 +20,7 @@ struct ContentView: View { private let bottomActionBarClearance: CGFloat = 132 @State private var isModeEditorPresented = false + @State private var isShortcutGuidesPresented = false @State private var renamingAnchorID: UUID? @State private var anchorNameDraft = "" @@ -58,6 +59,10 @@ struct ContentView: View { renameAnchorSheet .presentationBackground(.clear) } + .sheet(isPresented: $isShortcutGuidesPresented) { + ShortcutGuidesSheet() + .presentationBackground(.clear) + } .anclaFamilyActivityPicker( isPresented: $viewModel.isPickerPresented, selection: $viewModel.draftSelection @@ -131,6 +136,13 @@ struct ContentView: View { private var headlineSection: some View { VStack(alignment: .leading, spacing: 10) { + if viewModel.currentModeIsStrict { + Text("STRICT MODE") + .font(.ancla(11, weight: .semibold)) + .foregroundStyle(AnclaTheme.warningText) + .tracking(1.6) + } + Text(viewModel.diagnostics.headline) .font(.ancla(40, weight: .medium)) .foregroundStyle(AnclaTheme.primaryText) @@ -190,6 +202,38 @@ struct ContentView: View { } ) + if viewModel.currentModeIsStrict { + surfaceDivider + + sectionBlock( + title: "Strict mode", + content: { + VStack(spacing: 12) { + informativeRow( + title: strictModeTitle, + detail: strictModeDetail, + accentColor: AnclaTheme.warningText, + highlight: viewModel.canReleaseActiveSession, + trailingText: "Tighter" + ) + + Button { + isShortcutGuidesPresented = true + } label: { + actionRow( + icon: "bolt.horizontal.circle", + title: "Review Apple app guides", + detail: "Safari, Settings, Messages, Mail, Phone, and Calendar still need Shortcuts automations.", + isLoading: false + ) + } + .buttonStyle(AnclaPressableButtonStyle()) + .disabled(viewModel.isBusy) + } + } + ) + } + surfaceDivider sectionBlock( @@ -555,6 +599,12 @@ struct ContentView: View { Spacer(minLength: 0) + if mode.isStrict { + Text("Strict") + .font(.ancla(12, weight: .medium)) + .foregroundStyle(AnclaTheme.warningText) + } + if isArmed { Text("Active") .font(.ancla(12, weight: .medium)) @@ -798,7 +848,12 @@ struct ContentView: View { return "Create or choose a mode before starting a session." } - return viewModel.selectionSummary(for: currentMode) + let summary = viewModel.selectionSummary(for: currentMode) + guard currentMode.isStrict else { + return summary + } + + return "\(summary) • strict mode" } private var anchorDetail: String { @@ -846,6 +901,10 @@ struct ContentView: View { case .armed: return sessionWaitingDetail case .mismatchedTag: + if viewModel.currentModeIsStrict { + return "A different anchor was scanned. Strict mode stays active until the right anchor is used. \(emergencyCountSentence)" + } + return "A different anchor was scanned. The session remains active. \(emergencyCountSentence)" case .released: return "The most recent session was released successfully." @@ -1073,6 +1132,10 @@ struct ContentView: View { private var sessionWaitingDetail: String { if let activePairedTag = viewModel.activePairedTag { + if viewModel.currentModeIsStrict { + return "Strict mode is active. \(activePairedTag.displayName) is the only release path. \(emergencyCountSentence)" + } + return "The current session remains active until \(activePairedTag.displayName) is scanned. \(emergencyCountSentence)" } @@ -1156,6 +1219,18 @@ struct ContentView: View { return "Remove this paired anchor from this iPhone." } + + private var strictModeTitle: String { + viewModel.canReleaseActiveSession ? "Strict mode is active" : "Strict mode is ready" + } + + private var strictModeDetail: String { + if viewModel.canReleaseActiveSession { + return "This session is meant to feel harder to bypass. Close obvious loopholes with the Apple app shortcut guides before you rely on it." + } + + return "This mode uses stronger, more committed copy and a native-Apple-app checklist so the easy bypasses are harder to ignore." + } } private extension View { diff --git a/ios/ancla-app/mode-editor-view.swift b/ios/ancla-app/mode-editor-view.swift index 4be4d81..05a1d7d 100644 --- a/ios/ancla-app/mode-editor-view.swift +++ b/ios/ancla-app/mode-editor-view.swift @@ -6,6 +6,7 @@ struct ModeEditorView: View { let onChooseSelection: () -> Void @Environment(\.dismiss) private var dismiss + @State private var isShortcutGuidesPresented = false var body: some View { ZStack(alignment: .top) { @@ -89,7 +90,7 @@ struct ModeEditorView: View { sectionLabel("RELEASE") .padding(.top, 60) - Text("This mode uses the paired anchor and keeps its session state on this iPhone.") + Text("This mode uses the paired anchor and keeps its session state on this iPhone. Turn on strict mode if you want the harder-to-bypass version of this ritual.") .font(.ancla(16)) .foregroundStyle(AnclaTheme.secondaryText) .frame(maxWidth: .infinity, alignment: .leading) @@ -133,6 +134,9 @@ struct ModeEditorView: View { divider .padding(.top, 24) + strictModePanel + .padding(.top, 40) + HStack(alignment: .center) { VStack(alignment: .leading, spacing: 6) { Text("Set as Primary") @@ -196,6 +200,10 @@ struct ModeEditorView: View { .preferredColorScheme(.dark) .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) + .sheet(isPresented: $isShortcutGuidesPresented) { + ShortcutGuidesSheet() + .presentationBackground(.clear) + } } private func sectionLabel(_ title: String) -> some View { @@ -210,4 +218,70 @@ struct ModeEditorView: View { .fill(AnclaTheme.panelStroke.opacity(0.6)) .frame(height: 1) } + + private var strictModePanel: some View { + VStack(alignment: .leading, spacing: 14) { + sectionLabel("STRICT MODE") + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("Use stricter mode copy") + .font(.ancla(16)) + .foregroundStyle(AnclaTheme.primaryText) + + Text("Highlight the stricter version of this mode and surface the Apple app shortcut guides that close easy loopholes.") + .font(.ancla(12)) + .foregroundStyle(AnclaTheme.tertiaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Toggle("", isOn: $viewModel.draftModeIsStrict) + .labelsHidden() + .tint(AnclaTheme.ctaFill) + } + + if viewModel.draftModeIsStrict { + Text("Native Apple apps still need Shortcuts automations because iOS does not let Ancla hard-block every built-in app directly.") + .font(.ancla(13)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + isShortcutGuidesPresented = true + } label: { + HStack { + Image(systemName: "bolt.horizontal.circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AnclaTheme.primaryText) + + Text("Review Apple app shortcut guides") + .font(.ancla(15, weight: .medium)) + .foregroundStyle(AnclaTheme.primaryText) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AnclaTheme.tertiaryText) + } + .padding(.horizontal, 16) + .frame(height: 52) + } + .buttonStyle(AnclaPressableButtonStyle()) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + } + } } diff --git a/ios/ancla-app/native-apple-shortcut-guides.swift b/ios/ancla-app/native-apple-shortcut-guides.swift new file mode 100644 index 0000000..fe65025 --- /dev/null +++ b/ios/ancla-app/native-apple-shortcut-guides.swift @@ -0,0 +1,50 @@ +import Foundation + +struct NativeAppleShortcutGuide: Identifiable, Equatable { + let id: String + let title: String + let apps: String + let summary: String + let steps: [String] +} + +enum NativeAppleShortcutGuides { + static let guides: [NativeAppleShortcutGuide] = [ + NativeAppleShortcutGuide( + id: "safari-settings", + title: "Safari and Settings", + apps: "Safari, Settings", + summary: "Use a personal automation that opens Ancla the moment Safari or Settings launches.", + steps: [ + "Open Shortcuts and create a new Personal Automation.", + "Choose App, set Is Opened, and enable Run Immediately.", + "Select Safari or Settings as the trigger app.", + "Add an Open App action that launches Ancla.", + ] + ), + NativeAppleShortcutGuide( + id: "messages-mail", + title: "Messages and Mail", + apps: "Messages, Mail", + summary: "Bounce communication apps back into Ancla while a strict session is active.", + steps: [ + "Duplicate the same Personal Automation flow for Messages or Mail.", + "Keep the trigger on Is Opened so the redirect happens instantly.", + "Use Open App -> Ancla as the only action.", + "Test each automation once before relying on it.", + ] + ), + NativeAppleShortcutGuide( + id: "phone-calendar", + title: "Phone and Calendar", + apps: "Phone, Calendar", + summary: "Cover the common Apple defaults that are easy to reach from habit.", + steps: [ + "Create one automation per app so each shortcut stays obvious to audit.", + "Set the app trigger to Is Opened with Run Immediately.", + "Send the action straight into Ancla with Open App.", + "Use these only for sessions where the extra friction is worth it.", + ] + ), + ] +} diff --git a/ios/ancla-app/shortcut-guides-sheet.swift b/ios/ancla-app/shortcut-guides-sheet.swift new file mode 100644 index 0000000..f9c87a6 --- /dev/null +++ b/ios/ancla-app/shortcut-guides-sheet.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct ShortcutGuidesSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ZStack { + AnclaTheme.background + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + hero + + ForEach(NativeAppleShortcutGuides.guides) { guide in + guideCard(guide) + } + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 36) + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + dismiss() + } + .font(.ancla(14, weight: .medium)) + .foregroundStyle(AnclaTheme.secondaryText) + } + } + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + HStack { + Button("Done") { + dismiss() + } + .font(.ancla(14, weight: .medium)) + .foregroundStyle(AnclaTheme.secondaryText) + + Spacer() + + Text("Apple app guides") + .font(.ancla(18, weight: .bold)) + .foregroundStyle(AnclaTheme.primaryText) + + Spacer() + + Color.clear + .frame(width: 42, height: 18) + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 12) + .background(AnclaTheme.background) + } + .preferredColorScheme(.dark) + } + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Native Apple apps need extra friction.") + .font(.ancla(30, weight: .medium)) + .foregroundStyle(AnclaTheme.primaryText) + + Text("Shortcuts automations cannot hard-block these apps, but they can bounce Safari, Settings, Messages, Mail, Phone, or Calendar straight back into Ancla while a strict session is active.") + .font(.ancla(14)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func guideCard(_ guide: NativeAppleShortcutGuide) -> some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text(guide.title) + .font(.ancla(18, weight: .semibold)) + .foregroundStyle(AnclaTheme.primaryText) + + Text(guide.apps) + .font(.ancla(12, weight: .medium)) + .foregroundStyle(AnclaTheme.tertiaryText) + + Text(guide.summary) + .font(.ancla(13)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(guide.steps.enumerated()), id: \.offset) { index, step in + HStack(alignment: .top, spacing: 10) { + Text("\(index + 1)") + .font(.anclaMono(11)) + .foregroundStyle(AnclaTheme.primaryText) + .frame(width: 18, alignment: .leading) + + Text(step) + .font(.ancla(13)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.7), lineWidth: 1) + ) + ) + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(AnclaTheme.panel) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.8), lineWidth: 1) + ) + ) + } +} diff --git a/ios/ancla-core-tests/ancla-core-tests.swift b/ios/ancla-core-tests/ancla-core-tests.swift index 3999d4d..23c19c1 100644 --- a/ios/ancla-core-tests/ancla-core-tests.swift +++ b/ios/ancla-core-tests/ancla-core-tests.swift @@ -2,6 +2,30 @@ import XCTest @testable import AnclaCore final class AnclaCoreTests: XCTestCase { + func testBlockModeDefaultsToNonStrictAndCanOptIn() { + let defaultMode = BlockMode(name: "Work", selectionData: Data(), isDefault: true) + let strictMode = BlockMode(name: "Locked down", selectionData: Data(), isDefault: false, isStrict: true) + + XCTAssertFalse(defaultMode.isStrict) + XCTAssertTrue(strictMode.isStrict) + } + + func testBlockModeDecodeDefaultsStrictFlagWhenMissing() throws { + let encoded = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "Work", + "selectionData": "", + "isDefault": true + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(BlockMode.self, from: encoded) + + XCTAssertFalse(decoded.isStrict) + XCTAssertEqual(decoded.name, "Work") + } + func testSortedModesPlacesDefaultFirstThenAlphabetical() { let laterDefault = BlockMode(name: "Work", selectionData: Data(), isDefault: true) let alpha = BlockMode(name: "Calls", selectionData: Data(), isDefault: false) diff --git a/ios/ancla-shared/ancla-activity-selection.swift b/ios/ancla-shared/ancla-activity-selection.swift index 235cf01..7be8cfb 100644 --- a/ios/ancla-shared/ancla-activity-selection.swift +++ b/ios/ancla-shared/ancla-activity-selection.swift @@ -17,13 +17,15 @@ extension BlockMode { id: UUID = UUID(), name: String, selection: FamilyActivitySelection, - isDefault: Bool = false + isDefault: Bool = false, + isStrict: Bool = false ) throws { self.init( id: id, name: name, selectionData: try JSONEncoder().encode(selection), - isDefault: isDefault + isDefault: isDefault, + isStrict: isStrict ) } diff --git a/ios/ancla-shared/ancla-models.swift b/ios/ancla-shared/ancla-models.swift index bb7964e..603bfab 100644 --- a/ios/ancla-shared/ancla-models.swift +++ b/ios/ancla-shared/ancla-models.swift @@ -20,16 +20,41 @@ struct PairedTag: Codable, Equatable, Identifiable { } struct BlockMode: Codable, Equatable, Identifiable { + private enum CodingKeys: String, CodingKey { + case id + case name + case selectionData + case isDefault + case isStrict + } + let id: UUID var name: String var selectionData: Data var isDefault: Bool + var isStrict: Bool - init(id: UUID = UUID(), name: String, selectionData: Data = Data(), isDefault: Bool = false) { + init( + id: UUID = UUID(), + name: String, + selectionData: Data = Data(), + isDefault: Bool = false, + isStrict: Bool = false + ) { self.id = id self.name = name self.selectionData = selectionData self.isDefault = isDefault + self.isStrict = isStrict + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + selectionData = try container.decode(Data.self, forKey: .selectionData) + isDefault = try container.decode(Bool.self, forKey: .isDefault) + isStrict = try container.decodeIfPresent(Bool.self, forKey: .isStrict) ?? false } } diff --git a/ios/ancla-tests/app-view-model-tests.swift b/ios/ancla-tests/app-view-model-tests.swift index 1f487d5..5cf251d 100644 --- a/ios/ancla-tests/app-view-model-tests.swift +++ b/ios/ancla-tests/app-view-model-tests.swift @@ -26,6 +26,23 @@ final class AppViewModelTests: XCTestCase { XCTAssertTrue(viewModel.snapshot.modes[1].isDefault) } + func testSaveModePersistsStrictFlagForSideloadMode() async throws { + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: InMemorySnapshotStore(), + stickerPairingService: FakeStickerPairingService() + ) + + viewModel.draftModeName = "Locked down" + viewModel.draftModeIsStrict = true + await viewModel.saveMode() + + let savedMode = try XCTUnwrap(viewModel.snapshot.modes.first) + XCTAssertTrue(savedMode.isStrict) + XCTAssertTrue(viewModel.currentModeIsStrict) + XCTAssertFalse(viewModel.draftModeIsStrict) + } + func testDeleteModeClearsArmedSessionAndReassignsDefault() async throws { let selection = FamilyActivitySelection() let firstMode = try BlockMode(name: "Focus", selection: selection, isDefault: true) @@ -202,6 +219,39 @@ final class AppViewModelTests: XCTestCase { XCTAssertEqual(shielding.appliedModeIDs, [secondMode.id]) } + func testPrepareDraftForEditingModeLoadsAndUpdatesStrictFlag() async throws { + let strictMode = BlockMode( + name: "Locked down", + selectionData: Data(), + isDefault: true, + isStrict: true + ) + let store = InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTag: nil, + modes: [strictMode], + activeSession: nil + ) + ) + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: store, + stickerPairingService: FakeStickerPairingService() + ) + + viewModel.prepareDraftForEditingMode(strictMode.id) + XCTAssertTrue(viewModel.draftModeIsStrict) + XCTAssertTrue(viewModel.currentModeIsStrict) + + viewModel.draftModeIsStrict = false + await viewModel.saveMode() + + let updatedMode = try XCTUnwrap(viewModel.snapshot.modes.first) + XCTAssertFalse(updatedMode.isStrict) + XCTAssertFalse(viewModel.currentModeIsStrict) + } + func testWrongStickerKeepsSessionArmedAndAllowsRetry() async throws { let selection = FamilyActivitySelection() let mode = try BlockMode(name: "Work", selection: selection, isDefault: true)