diff --git a/Examples/SwiftUIControlTest b/Examples/SwiftUIControlTest deleted file mode 160000 index bbdcc11..0000000 --- a/Examples/SwiftUIControlTest +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bbdcc11da8b531345bec8ac1941d67327429677b diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8b2b651 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.pbxproj @@ -0,0 +1,356 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC8A72F047C5D0019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIControlsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftUIControlsExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8942F047C480019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC8A72F047C5D0019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC88E2F047C480019EC55 = { + isa = PBXGroup; + children = ( + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */, + 33DAC8982F047C480019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC8982F047C480019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC8962F047C480019EC55 /* SwiftUIControlsExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC8A22F047C490019EC55 /* Build configuration list for PBXNativeTarget "SwiftUIControlsExample" */; + buildPhases = ( + 33DAC8932F047C480019EC55 /* Sources */, + 33DAC8942F047C480019EC55 /* Frameworks */, + 33DAC8952F047C480019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC8992F047C480019EC55 /* SwiftUIControlsExample */, + ); + name = SwiftUIControlsExample; + packageProductDependencies = ( + 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */, + ); + productName = SwiftUIControlsExample; + productReference = 33DAC8972F047C480019EC55 /* SwiftUIControlsExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC88F2F047C480019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC8962F047C480019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8922F047C480019EC55 /* Build configuration list for PBXProject "SwiftUIControlsExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC88E2F047C480019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8A52F047C5D0019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC8982F047C480019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC8962F047C480019EC55 /* SwiftUIControlsExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8952F047C480019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8932F047C480019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC8A02F047C490019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8A12F047C490019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 33DAC8A32F047C490019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUIControlsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8A42F047C490019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUIControlsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8922F047C480019EC55 /* Build configuration list for PBXProject "SwiftUIControlsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8A02F047C490019EC55 /* Debug */, + 33DAC8A12F047C490019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC8A22F047C490019EC55 /* Build configuration list for PBXNativeTarget "SwiftUIControlsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8A32F047C490019EC55 /* Debug */, + 33DAC8A42F047C490019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8A52F047C5D0019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC8A62F047C5D0019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC88F2F047C480019EC55 /* Project object */; +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift new file mode 100644 index 0000000..5d61c18 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/ContentView.swift @@ -0,0 +1,264 @@ +// +// ContentView.swift +// SwiftUIControlsExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import AnalyticsLive + +typealias Button = SignalButton +typealias TextField = SignalTextField +typealias SecureField = SignalSecureField +typealias List = SignalList +typealias Picker = SignalPicker +typealias Slider = SignalSlider +typealias Stepper = SignalStepper +typealias Link = SignalLink +typealias Menu = SignalMenu +typealias DatePicker = SignalDatePicker +typealias ColorPicker = SignalColorPicker + +struct ContentView: View { + // State for interactive controls + @State private var textFieldValue = "" + @State private var secureFieldValue = "" + @State private var textEditorValue = "Some text" + @State private var toggleValue = false + @State private var sliderValue: Double = 0.5 + @State private var stepperValue: Int = 0 + @State private var pickerValue = 0 + @State private var datePickerValue = Date() + @State private var colorPickerValue = Color.blue + + @State private var hierarchyOutput: String = "Tap 'Inspect Hierarchy' to analyze" + + var body: some View { + NavigationStack { + List { + Section("Controls Under Test") { + // Button + Button("Test Button") { + print("Button tapped") + } + .accessibilityIdentifier("testButton") + + // TextField + TextField("Enter text", text: $textFieldValue) + .accessibilityIdentifier("testTextField") + + // SecureField + SecureField("Password", text: $secureFieldValue) + .accessibilityIdentifier("testSecureField") + + // TextEditor (needs fixed height in List) + TextEditor(text: $textEditorValue) + .frame(height: 80) + .accessibilityIdentifier("testTextEditor") + + // Toggle + SignalToggle("Test Toggle", isOn: $toggleValue) + .accessibilityIdentifier("testToggle") + + // Slider + VStack(alignment: .leading) { + Text("Slider: \(sliderValue, specifier: "%.2f")") + Slider("Volume", value: $sliderValue) + .accessibilityIdentifier("testSlider") + } + + // Stepper + Stepper("Value: \(stepperValue)", value: $stepperValue) + .accessibilityIdentifier("testStepper") + + // Picker (default/menu style) + Picker("Option", selection: $pickerValue) { + Text("Option A").tag(0) + Text("Option B").tag(1) + Text("Option C").tag(2) + } + .accessibilityIdentifier("testPicker") + + // Picker (segmented style) + Picker("Segmented", selection: $pickerValue) { + Text("A").tag(0) + Text("B").tag(1) + Text("C").tag(2) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("testSegmentedPicker") + + // DatePicker + DatePicker("Date", selection: $datePickerValue) + .accessibilityIdentifier("testDatePicker") + + // ColorPicker + ColorPicker("Color", selection: $colorPickerValue) + .accessibilityIdentifier("testColorPicker") + + // Menu + Menu("Test Menu") { + Button("Action 1") { } + Button("Action 2") { } + } + .accessibilityIdentifier("testMenu") + + // Link + Link("Test Link", destination: URL(string: "https://segment.com")!) + .accessibilityIdentifier("testLink") + } + + Section("List Selection Tests") { + NavigationLink("Single Selection List") { + SingleSelectionListView() + } + NavigationLink("Multi Selection List") { + MultiSelectionListView() + } + NavigationLink("Tap to Select (always edit mode)") { + TapToSelectListView() + } + NavigationLink("Edit Mode Selection (tap Edit first)") { + EditModeSelectionListView() + } + } + + Section("Hierarchy Analysis") { + Button("Inspect Hierarchy") { + hierarchyOutput = inspectViewHierarchy() + } + .buttonStyle(.borderedProminent) + + Text(hierarchyOutput) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .navigationTitle("Control Inspector") + } + } + + func inspectViewHierarchy() -> String { + var output = "" + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return "Could not access window" + } + + output += "=== UIKit View Hierarchy ===\n\n" + output += inspectView(window, depth: 0) + + return output + } + + func inspectView(_ view: UIView, depth: Int) -> String { + var output = "" + let indent = String(repeating: " ", count: depth) + let viewType = String(describing: type(of: view)) + + // Highlight interesting types + let isUIKit = viewType.hasPrefix("UI") && !viewType.contains("Hosting") + let isHosting = viewType.contains("Hosting") + let isSwiftUI = viewType.hasPrefix("_") || viewType.contains("SwiftUI") + + var marker = "" + if isUIKit && !isHosting { + marker = " 🟡 UIKit" + } + + // Get accessibility identifier if present + var accessId = "" + if let id = view.accessibilityIdentifier, !id.isEmpty { + accessId = " [id: \(id)]" + } + + output += "\(indent)\(viewType)\(accessId)\(marker)\n" + + // Recurse into subviews + for subview in view.subviews { + output += inspectView(subview, depth: depth + 1) + } + + return output + } +} + +// MARK: - List Selection Test Views + +struct SingleSelectionListView: View { + @State private var selection: String? = nil + let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"] + + var body: some View { + List(items, id: \.self, selection: $selection) { item in + Text(item) + } + .navigationTitle("Single Selection") + .toolbar { + EditButton() + } + .onChange(of: selection) { oldValue, newValue in + print("Single selection changed: \(String(describing: newValue))") + } + } +} + +struct MultiSelectionListView: View { + @State private var selection: Set = [] + let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"] + + var body: some View { + List(items, id: \.self, selection: $selection) { item in + Text(item) + } + .navigationTitle("Multi Selection") + .toolbar { + EditButton() + } + .onChange(of: selection) { oldValue, newValue in + print("Multi selection changed: \(newValue)") + } + } +} + +// Tap-to-select pattern (edit mode forced on, checkmarks appear on tap) +struct TapToSelectListView: View { + @State private var selection: Set = [] + let items = ["Red", "Green", "Blue", "Yellow", "Purple"] + + var body: some View { + List(items, id: \.self, selection: $selection) { item in + Text(item) + } + .navigationTitle("Tap to Select") + .environment(\.editMode, .constant(.active)) // Force edit mode for checkmarks + .onChange(of: selection) { oldValue, newValue in + print("Tap selection changed: \(newValue)") + } + } +} + +// Edit mode selection - must tap Edit button to enable selection +struct EditModeSelectionListView: View { + @State private var selection: Set = [] + let items = ["Coffee", "Tea", "Juice", "Water", "Soda"] + + var body: some View { + List(items, id: \.self, selection: $selection) { item in + Text(item) + } + .navigationTitle("Edit Mode Selection") + .toolbar { + EditButton() + } + .onChange(of: selection) { oldValue, newValue in + print("Edit mode selection changed: \(newValue)") + } + } +} + +#Preview { + ContentView() +} diff --git a/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift new file mode 100644 index 0000000..9488b43 --- /dev/null +++ b/Examples/SwiftUIControlsExample/SwiftUIControlsExample/SwiftUIControlsExampleApp.swift @@ -0,0 +1,50 @@ +// +// SwiftUIControlsExampleApp.swift +// SwiftUIControlsExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +struct SwiftUIControlsExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } + + init() { + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", // this is your segment writeKey. + relayCount: 1, // lets us see signals quickly ... , the default is 20. + broadcasters: [DebugBroadcaster()], // see console output. Default is just SegmentBroadcaster. + useUIKitAutoSignal: true, // automatically forward UIKit interaction signals to your event generators. + useSwiftUIAutoSignal: true, // automatically forward SwiftUI control interaction signals to your event generators. + useNetworkAutoSignal: true) // automatically forward network req/recv signals to your event generators. + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e4f273d --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.pbxproj @@ -0,0 +1,356 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC85B2F046BC80019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC85A2F046BC80019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUINavExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SwiftUINavExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8482F046B010019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC85B2F046BC80019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC8422F046B010019EC55 = { + isa = PBXGroup; + children = ( + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */, + 33DAC84C2F046B010019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC84C2F046B010019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC84A2F046B010019EC55 /* SwiftUINavExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC8562F046B020019EC55 /* Build configuration list for PBXNativeTarget "SwiftUINavExample" */; + buildPhases = ( + 33DAC8472F046B010019EC55 /* Sources */, + 33DAC8482F046B010019EC55 /* Frameworks */, + 33DAC8492F046B010019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC84D2F046B010019EC55 /* SwiftUINavExample */, + ); + name = SwiftUINavExample; + packageProductDependencies = ( + 33DAC85A2F046BC80019EC55 /* AnalyticsLive */, + ); + productName = SwiftUINavExample; + productReference = 33DAC84B2F046B010019EC55 /* SwiftUINavExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC8432F046B010019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC84A2F046B010019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8462F046B010019EC55 /* Build configuration list for PBXProject "SwiftUINavExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC8422F046B010019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8592F046BC80019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC84C2F046B010019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC84A2F046B010019EC55 /* SwiftUINavExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8492F046B010019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8472F046B010019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC8542F046B020019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8552F046B020019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 33DAC8572F046B020019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUINavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8582F046B020019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.SwiftUINavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8462F046B010019EC55 /* Build configuration list for PBXProject "SwiftUINavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8542F046B020019EC55 /* Debug */, + 33DAC8552F046B020019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC8562F046B020019EC55 /* Build configuration list for PBXNativeTarget "SwiftUINavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8572F046B020019EC55 /* Debug */, + 33DAC8582F046B020019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8592F046BC80019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC85A2F046BC80019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC8432F046B010019EC55 /* Project object */; +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift b/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift new file mode 100644 index 0000000..1f197aa --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/ContentView.swift @@ -0,0 +1,274 @@ +// +// ContentView.swift +// SwiftUINavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI + +// MARK: - Main Tab View + +struct ContentView: View { + var body: some View { + TabView { + Tab("Navigation", systemImage: "arrow.triangle.turn.up.right.diamond") { + NavigationTestView() + } + Tab("Sheets", systemImage: "square.stack") { + SheetTestView() + } + Tab("Settings", systemImage: "gear") { + SettingsView() + } + } + } +} + +// MARK: - Navigation Tests + +struct NavigationTestView: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List { + Section("Basic NavigationLink") { + // Classic NavigationLink with destination closure + NavigationLink("Detail View (closure)") { + DetailView(title: "From Closure") + } + + // NavigationLink with label + NavigationLink { + DetailView(title: "From Label Builder") + } label: { + Label("Detail View (label)", systemImage: "star") + } + } + + Section("Value-Based Navigation") { + // Value-based - uses navigationDestination(for:) + NavigationLink("Product: iPhone", value: Product(name: "iPhone", price: 999)) + NavigationLink("Product: MacBook", value: Product(name: "MacBook", price: 1999)) + NavigationLink("Product: AirPods", value: Product(name: "AirPods", price: 249)) + } + + Section("Programmatic Navigation") { + Button("Push 3 screens programmatically") { + path.append(Product(name: "First", price: 1)) + path.append(Product(name: "Second", price: 2)) + path.append(Product(name: "Third", price: 3)) + } + + Button("Go to Category -> Product") { + path.append(Category(name: "Electronics")) + path.append(Product(name: "Gadget", price: 99)) + } + } + + Section("Nested Navigation") { + NavigationLink("Go to List View") { + NestedListView() + } + } + } + .navigationTitle("Navigation Tests") + .navigationDestination(for: Product.self) { product in + ProductDetailView(product: product) + } + .navigationDestination(for: Category.self) { category in + CategoryView(category: category) + } + } + } +} + +// MARK: - Detail Views + +struct DetailView: View { + let title: String + + var body: some View { + VStack { + Text("This is \(title)") + .font(.headline) + Text("A basic detail view") + .foregroundStyle(.secondary) + } + .navigationTitle(title) + } +} + +struct ProductDetailView: View { + let product: Product + + var body: some View { + VStack(spacing: 20) { + Text(product.name) + .font(.largeTitle) + Text("$\(product.price)") + .font(.title) + .foregroundStyle(.green) + } + .navigationTitle(product.name) + } +} + +struct CategoryView: View { + let category: Category + + var body: some View { + List { + Text("Category: \(category.name)") + NavigationLink("View Sample Product", value: Product(name: "Sample", price: 42)) + } + .navigationTitle(category.name) + } +} + +struct NestedListView: View { + var body: some View { + List { + NavigationLink("Nested Item 1") { + NestedDetailView(item: 1) + } + NavigationLink("Nested Item 2") { + NestedDetailView(item: 2) + } + NavigationLink("Nested Item 3") { + NestedDetailView(item: 3) + } + } + .navigationTitle("Nested List") + } +} + +struct NestedDetailView: View { + let item: Int + + var body: some View { + Text("Nested Item \(item) Detail") + .navigationTitle("Item \(item)") + } +} + +// MARK: - Sheet Tests + +struct SheetTestView: View { + @State private var showSheet = false + @State private var showFullScreen = false + + var body: some View { + NavigationStack { + List { + Section("Sheet Presentations") { + Button("Show Sheet") { + showSheet = true + } + + Button("Show Full Screen Cover") { + showFullScreen = true + } + } + + Section("Sheet with Navigation") { + Button("Sheet with NavigationStack") { + showSheet = true + } + } + } + .navigationTitle("Sheet Tests") + .sheet(isPresented: $showSheet) { + SheetContentView() + } + .fullScreenCover(isPresented: $showFullScreen) { + FullScreenContentView(isPresented: $showFullScreen) + } + } + } +} + +struct SheetContentView: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + VStack { + Text("This is a sheet!") + .font(.headline) + + NavigationLink("Navigate inside sheet") { + Text("Pushed inside sheet!") + .navigationTitle("Sheet Detail") + } + .padding() + } + .navigationTitle("Sheet View") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +struct FullScreenContentView: View { + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + Text("Full screen cover!") + .navigationTitle("Full Screen") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { isPresented = false } + } + } + } + } +} + +// MARK: - Settings (No navigation title set!) + +struct SettingsView: View { + var body: some View { + NavigationStack { + List { + Text("Setting 1") + Text("Setting 2") + Text("Setting 3") + + NavigationLink("About") { + AboutView() + } + } + // Intentionally NO navigationTitle here - let's see what we capture + } + } +} + +struct AboutView: View { + var body: some View { + Text("SignalTestBed v1.0") + .navigationTitle("About") + } +} + +// MARK: - Models + +struct Product: Hashable { + let name: String + let price: Int +} + +struct Category: Hashable { + let name: String +} + +// MARK: - Preview + +#Preview { + ContentView() +} diff --git a/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift new file mode 100644 index 0000000..37da83d --- /dev/null +++ b/Examples/SwiftUINavExample/SwiftUINavExample/SwiftUINavExampleApp.swift @@ -0,0 +1,49 @@ +// +// SwiftUINavExampleApp.swift +// SwiftUINavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import SwiftUI +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +struct SwiftUINavExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } + + init() { + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", // this is your segment writeKey. + //relayCount: 1, // lets us see signals quickly ... , the default is 20. + broadcasters: [DebugBroadcaster()], // see console output. Default is just SegmentBroadcaster. + useSwiftUIAutoSignal: true, // automatically forward swiftUI control interaction signals to your event generators. + useNetworkAutoSignal: true) // automatically forward network req/recv signals to your event generators. + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9065362 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.pbxproj @@ -0,0 +1,367 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 33DAC8872F0477990019EC55 /* AnalyticsLive in Frameworks */ = {isa = PBXBuildFile; productRef = 33DAC8862F0477990019EC55 /* AnalyticsLive */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitNavExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 33DAC87D2F0476A70019EC55 /* Exceptions for "UIKitNavExample" folder in "UIKitNavExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 33DAC86A2F0476A60019EC55 /* UIKitNavExample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 33DAC87D2F0476A70019EC55 /* Exceptions for "UIKitNavExample" folder in "UIKitNavExample" target */, + ); + path = UIKitNavExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33DAC8682F0476A60019EC55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 33DAC8872F0477990019EC55 /* AnalyticsLive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33DAC8622F0476A60019EC55 = { + isa = PBXGroup; + children = ( + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */, + 33DAC86C2F0476A60019EC55 /* Products */, + ); + sourceTree = ""; + }; + 33DAC86C2F0476A60019EC55 /* Products */ = { + isa = PBXGroup; + children = ( + 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33DAC86A2F0476A60019EC55 /* UIKitNavExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33DAC87E2F0476A70019EC55 /* Build configuration list for PBXNativeTarget "UIKitNavExample" */; + buildPhases = ( + 33DAC8672F0476A60019EC55 /* Sources */, + 33DAC8682F0476A60019EC55 /* Frameworks */, + 33DAC8692F0476A60019EC55 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 33DAC86D2F0476A60019EC55 /* UIKitNavExample */, + ); + name = UIKitNavExample; + packageProductDependencies = ( + 33DAC8862F0477990019EC55 /* AnalyticsLive */, + ); + productName = UIKitNavExample; + productReference = 33DAC86B2F0476A60019EC55 /* UIKitNavExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33DAC8632F0476A60019EC55 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 33DAC86A2F0476A60019EC55 = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = 33DAC8662F0476A60019EC55 /* Build configuration list for PBXProject "UIKitNavExample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33DAC8622F0476A60019EC55; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 33DAC8852F0477990019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 33DAC86C2F0476A60019EC55 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33DAC86A2F0476A60019EC55 /* UIKitNavExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33DAC8692F0476A60019EC55 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33DAC8672F0476A60019EC55 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 33DAC87F2F0476A70019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = UIKitNavExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.UIKitNavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 33DAC8802F0476A70019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = UIKitNavExample/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.segment.UIKitNavExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 33DAC8812F0476A70019EC55 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33DAC8822F0476A70019EC55 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33DAC8662F0476A60019EC55 /* Build configuration list for PBXProject "UIKitNavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC8812F0476A70019EC55 /* Debug */, + 33DAC8822F0476A70019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33DAC87E2F0476A70019EC55 /* Build configuration list for PBXNativeTarget "UIKitNavExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33DAC87F2F0476A70019EC55 /* Debug */, + 33DAC8802F0476A70019EC55 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 33DAC8852F0477990019EC55 /* XCLocalSwiftPackageReference "../../../analytics-swift-live" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../analytics-swift-live"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 33DAC8862F0477990019EC55 /* AnalyticsLive */ = { + isa = XCSwiftPackageProductDependency; + productName = AnalyticsLive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33DAC8632F0476A60019EC55 /* Project object */; +} diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..82dea23 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "226ac2774b9590833966f6d13ad78a8190d097e5ace610e05206de14f85e3485", + "pins" : [ + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "f604cef21bb7269f76136c68bd664c6c993f8e28", + "version" : "1.9.1" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "substrata-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/substrata-swift.git", + "state" : { + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift b/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift new file mode 100644 index 0000000..6702eae --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/AppDelegate.swift @@ -0,0 +1,55 @@ +// +// AppDelegate.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit +import Segment +import AnalyticsLive + +extension Analytics { + static var main = Analytics(configuration: Configuration(writeKey: "") + .flushAt(3) + .setTrackedApplicationLifecycleEvents(.all)) +} + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // add the Analytics Live plugin to the timeline. + let lp = LivePlugins(fallbackFileURL: nil) + Analytics.main.add(plugin: lp) + + // add destination filters if desired ... + let filters = DestinationFilters() + Analytics.main.add(plugin: filters) + + // configure and add the Signals plugin if in use ... + let config = SignalsConfiguration( + writeKey: "", + //broadcasters: [DebugBroadcaster()], + useUIKitAutoSignal: true, + useNetworkAutoSignal: true) + + Signals.shared.useConfiguration(config) + Analytics.main.add(plugin: Signals.shared) + + // Enable debug logging for navigation observer + NavigationObserver.shared.debugLogging = true + + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard b/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample/Info.plist b/Examples/UIKitNavExample/UIKitNavExample/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift b/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift new file mode 100644 index 0000000..84efdeb --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/SceneDelegate.swift @@ -0,0 +1,48 @@ +// +// SceneDelegate.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + + // Create tab bar controller + let tabBarController = UITabBarController() + + // Navigation tab + let navigationVC = NavigationTestViewController() + let navNavController = UINavigationController(rootViewController: navigationVC) + navNavController.tabBarItem = UITabBarItem(title: "Navigation", image: UIImage(systemName: "arrow.triangle.turn.up.right.diamond"), tag: 0) + + // Sheets tab + let sheetsVC = SheetTestViewController() + let sheetsNavController = UINavigationController(rootViewController: sheetsVC) + sheetsNavController.tabBarItem = UITabBarItem(title: "Sheets", image: UIImage(systemName: "square.stack"), tag: 1) + + // Settings tab + let settingsVC = SettingsViewController() + let settingsNavController = UINavigationController(rootViewController: settingsVC) + settingsNavController.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), tag: 2) + + tabBarController.viewControllers = [navNavController, sheetsNavController, settingsNavController] + + window?.rootViewController = tabBarController + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) {} + func sceneDidBecomeActive(_ scene: UIScene) {} + func sceneWillResignActive(_ scene: UIScene) {} + func sceneWillEnterForeground(_ scene: UIScene) {} + func sceneDidEnterBackground(_ scene: UIScene) {} +} diff --git a/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift b/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift new file mode 100644 index 0000000..117407f --- /dev/null +++ b/Examples/UIKitNavExample/UIKitNavExample/ViewControllers.swift @@ -0,0 +1,419 @@ +// +// ViewControllers.swift +// UIKitNavExample +// +// Created by Brandon Sneed on 12/30/25. +// + +import UIKit + +// MARK: - Navigation Test View Controller + +class NavigationTestViewController: UITableViewController { + + private let sections = [ + ("Basic Push", ["Detail View", "Another Detail"]), + ("Value-Based", ["Product: iPhone", "Product: MacBook", "Product: AirPods"]), + ("Programmatic", ["Push 3 screens"]), + ("Nested", ["Go to List View"]) + ] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Navigation Tests" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].1.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = sections[indexPath.section].1[indexPath.row] + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch (indexPath.section, indexPath.row) { + case (0, 0): + let vc = DetailViewController(titleText: "Detail View") + navigationController?.pushViewController(vc, animated: true) + case (0, 1): + let vc = DetailViewController(titleText: "Another Detail") + navigationController?.pushViewController(vc, animated: true) + case (1, 0): + let vc = ProductDetailViewController(product: Product(name: "iPhone", price: 999)) + navigationController?.pushViewController(vc, animated: true) + case (1, 1): + let vc = ProductDetailViewController(product: Product(name: "MacBook", price: 1999)) + navigationController?.pushViewController(vc, animated: true) + case (1, 2): + let vc = ProductDetailViewController(product: Product(name: "AirPods", price: 249)) + navigationController?.pushViewController(vc, animated: true) + case (2, 0): + // Push 3 screens programmatically + let vc1 = ProductDetailViewController(product: Product(name: "First", price: 1)) + let vc2 = ProductDetailViewController(product: Product(name: "Second", price: 2)) + let vc3 = ProductDetailViewController(product: Product(name: "Third", price: 3)) + navigationController?.pushViewController(vc1, animated: false) + navigationController?.pushViewController(vc2, animated: false) + navigationController?.pushViewController(vc3, animated: true) + case (3, 0): + let vc = NestedListViewController() + navigationController?.pushViewController(vc, animated: true) + default: + break + } + } +} + +// MARK: - Detail View Controller + +class DetailViewController: UIViewController { + + private let titleText: String + + init(titleText: String) { + self.titleText = titleText + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = titleText + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "This is \(titleText)" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Product Detail View Controller + +struct Product { + let name: String + let price: Int +} + +class ProductDetailViewController: UIViewController { + + private let product: Product + + init(product: Product) { + self.product = product + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = product.name + view.backgroundColor = .systemBackground + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 20 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + + let nameLabel = UILabel() + nameLabel.text = product.name + nameLabel.font = .systemFont(ofSize: 34, weight: .bold) + + let priceLabel = UILabel() + priceLabel.text = "$\(product.price)" + priceLabel.font = .systemFont(ofSize: 24) + priceLabel.textColor = .systemGreen + + stack.addArrangedSubview(nameLabel) + stack.addArrangedSubview(priceLabel) + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Nested List View Controller + +class NestedListViewController: UITableViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Nested List" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 3 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = "Nested Item \(indexPath.row + 1)" + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let vc = NestedDetailViewController(item: indexPath.row + 1) + navigationController?.pushViewController(vc, animated: true) + } +} + +class NestedDetailViewController: UIViewController { + + private let item: Int + + init(item: Int) { + self.item = item + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Item \(item)" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "Nested Item \(item) Detail" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Sheet Test View Controller + +class SheetTestViewController: UITableViewController { + + private let options = ["Show Sheet", "Show Full Screen"] + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet Tests" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return options.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + cell.textLabel?.text = options[indexPath.row] + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch indexPath.row { + case 0: + let vc = SheetContentViewController() + let nav = UINavigationController(rootViewController: vc) + if let sheet = nav.sheetPresentationController { + sheet.detents = [.medium(), .large()] + } + present(nav, animated: true) + case 1: + let vc = FullScreenContentViewController() + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + default: + break + } + } +} + +class SheetContentViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet View" + view.backgroundColor = .systemBackground + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneTapped) + ) + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 20 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = "This is a sheet!" + label.font = .systemFont(ofSize: 17, weight: .semibold) + + let button = UIButton(type: .system) + button.setTitle("Navigate inside sheet", for: .normal) + button.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside) + + stack.addArrangedSubview(label) + stack.addArrangedSubview(button) + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc private func doneTapped() { + dismiss(animated: true) + } + + @objc private func navigateTapped() { + let vc = SheetDetailViewController() + navigationController?.pushViewController(vc, animated: true) + } +} + +class SheetDetailViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Sheet Detail" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "Pushed inside sheet!" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +class FullScreenContentViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Full Screen" + view.backgroundColor = .systemBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(closeTapped) + ) + + let label = UILabel() + label.text = "Full screen cover!" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} + +// MARK: - Settings View Controller (No title set!) + +class SettingsViewController: UITableViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Intentionally NO title here - let's see what we capture + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 4 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + if indexPath.row < 3 { + cell.textLabel?.text = "Setting \(indexPath.row + 1)" + cell.accessoryType = .none + } else { + cell.textLabel?.text = "About" + cell.accessoryType = .disclosureIndicator + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.row == 3 { + let vc = AboutViewController() + navigationController?.pushViewController(vc, animated: true) + } + } +} + +class AboutViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "About" + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "UIKitNavExample v1.0" + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} diff --git a/Signals.md b/Signals.md index a885ff1..f19b821 100644 --- a/Signals.md +++ b/Signals.md @@ -1,6 +1,6 @@ # Signals (Auto Instrumentation) -- [Signals (AnalyticsLive)](#signals-analyticslive) +- [Signals (Auto Instrumentation)](#signals-auto-instrumentation) - [Prerequisites](#prerequisites) - [Getting Started](#getting-started) - [Additional Setup](#additional-setup) @@ -70,7 +70,9 @@ dependencies: [ #### SwiftUI -SwiftUI automatic signal capture requires adding typealiases to your code. This is necessary because SwiftUI doesn't provide hooks for automatic instrumentation. +SwiftUI automatic interaction signal capture requires adding typealiases to your code. This is necessary because SwiftUI doesn't provide hooks for automatic instrumentation of user interactions. + +> **Note:** Navigation is captured automatically via swizzling and does NOT require typealiases. Only interaction controls need typealiases. 1. Enable SwiftUI auto-signals in your configuration: ```swift @@ -86,18 +88,21 @@ SwiftUI automatic signal capture requires adding typealiases to your code. This import SwiftUI import AnalyticsLive - // Navigation - typealias NavigationLink = SignalNavigationLink - typealias NavigationStack = SignalNavigationStack // iOS 16+ - // Selection & Input Controls typealias Button = SignalButton typealias TextField = SignalTextField typealias SecureField = SignalSecureField - typealias Picker = SignalPicker typealias Toggle = SignalToggle - typealias Slider = SignalSlider // Not available on tvOS - typealias Stepper = SignalStepper // Not available on tvOS + typealias Picker = SignalPicker + typealias DatePicker = SignalDatePicker + typealias ColorPicker = SignalColorPicker + typealias Link = SignalLink + typealias Menu = SignalMenu + + #if !os(tvOS) + typealias Slider = SignalSlider + typealias Stepper = SignalStepper + #endif // List & Collection Views typealias List = SignalList @@ -106,6 +111,9 @@ SwiftUI automatic signal capture requires adding typealiases to your code. This 3. Use the controls normally in your SwiftUI code: ```swift struct ContentView: View { + @State private var text = "" + @State private var isOn = false + var body: some View { NavigationStack { VStack { @@ -115,6 +123,9 @@ SwiftUI automatic signal capture requires adding typealiases to your code. This TextField("Enter text", text: $text) // Text changes will automatically generate signals + + Toggle("Enable Feature", isOn: $isOn) + // Toggle changes will automatically generate signals } } } @@ -123,6 +134,8 @@ SwiftUI automatic signal capture requires adding typealiases to your code. This > **Note:** The typealiases replace SwiftUI's native controls with signal-generating versions. Your code remains unchanged, but interactions are now automatically captured. +> **Implementation Detail:** The Signal* wrappers are extremely thin layers on top of the original SwiftUI controls. They pass through all initializers, modifiers, and behaviors exactly as the native controls do. The only addition is signal emission when the user interacts with the control. Your UI will look and behave identically to using the native SwiftUI controls directly. + #### UIKit UIKit automatic signal capture uses method swizzling and requires no code changes. @@ -136,29 +149,43 @@ UIKit automatic signal capture uses method swizzling and requires no code change )) ``` -2. That's it! The following UIKit interactions and navigation events are automatically captured via method swizzling: +2. That's it! The following UIKit interactions are automatically captured via method swizzling: - **Interactions:** + **Controls:** - `UIButton` taps + - `UISwitch` toggle events - `UISlider` value changes - `UIStepper` value changes - - `UISwitch` toggle events - - `UITextField` text changes - - `UITableViewCell` selections + - `UISegmentedControl` selection + - `UIDatePicker` value changes + - `UIPageControl` page changes + - `UIColorWell` color changes (iOS 14+) + - `UITextField` text editing + - `UIPickerView` row selection - **Navigation:** - - `UINavigationController` push/pop operations - - `UIViewController` modal presentations and dismissals - - `UITabBarController` tab switches + **Cells:** + - `UITableViewCell` selection + - `UICollectionViewCell` selection + + **Tab Bar:** + - `UITabBarController` tab selection ### Capture Navigation -Navigation capture is handled automatically when you enable SwiftUI or UIKit auto-signals: +Navigation capture is fully automatic when you enable SwiftUI or UIKit auto-signals. No typealiases or additional setup required. + +The following navigation events are captured via method swizzling: + +- `UINavigationController` push/pop operations +- `UIViewController` modal presentations and dismissals +- Screen appearance and disappearance -- **SwiftUI**: Captured through `SignalNavigationLink` and `SignalNavigationStack` when you add the typealiases -- **UIKit**: Captured automatically via `UINavigationController`, `UIViewController`, and `UITabBarController` swizzling +Navigation signals include rich metadata: +- Screen name (extracted from title, navigation item, accessibility label, or class name) +- Previous screen information +- SwiftUI view names when available -No additional setup required beyond enabling the appropriate auto-signal flags. +> **Note:** Both SwiftUI and UIKit apps benefit from this automatic navigation tracking. SwiftUI navigation ultimately uses UIKit under the hood, so the same swizzlers capture both. ### Capture Network @@ -175,12 +202,15 @@ Network capture automatically tracks URLSession requests and responses. )) ``` -2. Network requests made via URLSession are automatically captured, including: +2. Network requests are automatically captured, including: - Request URL, method, headers, and body - Response status, headers, and body - Request/response correlation via request ID -> **Note:** Third-party networking libraries that use URLSession underneath (like Alamofire) should work automatically. Segment API endpoints are automatically blocked to prevent recursive tracking. +Network tracking works with: +- `URLSession.shared` +- Custom `URLSessionConfiguration` instances +- Third-party networking libraries (Alamofire, etc.) #### Configuring Network Hosts @@ -221,7 +251,7 @@ Using the `SignalsConfiguration` object, you can control the destination, freque | **obfuscateDebugSignals** | No | Bool | Obfuscates signals being relayed to Segment. Default is **true**. | | **apiHost** | No | String | API host for signal relay. Default is **"signals.segment.io/v1"**. | | **useUIKitAutoSignal** | No | Bool | Enables automatic UIKit signal capture via method swizzling. Default is **false**. | -| **useSwiftUIAutoSignal** | No | Bool | Enables automatic SwiftUI signal capture (requires typealiases). Default is **false**. | +| **useSwiftUIAutoSignal** | No | Bool | Enables automatic SwiftUI signal capture (requires typealiases for interactions). Default is **false**. | | **useNetworkAutoSignal** | No | Bool | Enables automatic network signal capture for URLSession. Default is **false**. | | **allowedNetworkHosts** | No | [String] | Array of host patterns to track. Use `["*"]` for all hosts. Default is **["*"]**. | | **blockedNetworkHosts** | No | [String] | Array of host patterns to exclude from tracking. Default is **[]**. | diff --git a/Sources/AnalyticsLive/LivePlugins/Bundler.swift b/Sources/AnalyticsLive/LivePlugins/Bundler.swift index c23cba5..10cd5a4 100755 --- a/Sources/AnalyticsLive/LivePlugins/Bundler.swift +++ b/Sources/AnalyticsLive/LivePlugins/Bundler.swift @@ -64,7 +64,9 @@ internal class Bundler { if let tempLocalUrl = tempLocalUrl, error == nil { // Success do { - _ = try FileManager.default.replaceItemAt(localUrl, withItemAt: tempLocalUrl) + //_ = try FileManager.default.replaceItemAt(localUrl, withItemAt: tempLocalUrl) + try? FileManager.default.removeItem(at: localUrl) + _ = try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) success = true } catch (let writeError) { print("Error writing file \(localUrl) : \(writeError)") diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store b/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store index 1569c87..ed6fcba 100755 Binary files a/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store and b/Sources/AnalyticsLive/Signals/AutoTracking/.DS_Store differ diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Controls/ControlSignalSource.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/ControlSignalSource.swift new file mode 100644 index 0000000..960e2c1 --- /dev/null +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/ControlSignalSource.swift @@ -0,0 +1,53 @@ +// +// ControlSignalSource.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 12/30/25. +// + +#if canImport(UIKit) && !os(watchOS) + +import UIKit + +/// Detects whether a UIKit control is being used directly in a UIKit app +/// or is wrapped by SwiftUI (via UIViewRepresentable or similar). +/// +/// This allows universal swizzlers to work for both UIKit and SwiftUI apps +/// while reporting the correct SignalSource. +public struct ControlSignalSource { + + /// Determines the appropriate SignalSource for a UIView by walking up + /// its ancestor chain looking for SwiftUI hosting views. + /// + /// - Parameter view: The UIKit view to check + /// - Returns: `.autoSwiftUI` if the view is hosted by SwiftUI, `.autoUIKit` otherwise + public static func detect(for view: UIView) -> SignalSource { + var current: UIView? = view.superview + + while let v = current { + let typeName = String(describing: type(of: v)) + + // These indicate the view is hosted within SwiftUI + if typeName.contains("UIKitPlatformViewHost") || + typeName.contains("PlatformViewRepresentableAdaptor") || + typeName.contains("HostingView") { + return .autoSwiftUI + } + + current = v.superview + } + + return .autoUIKit + } + + /// Convenience method that accepts an optional view + /// + /// - Parameter view: The UIKit view to check (optional) + /// - Returns: `.autoSwiftUI` if the view is hosted by SwiftUI, `.autoUIKit` otherwise + public static func detect(for view: UIView?) -> SignalSource { + guard let view = view else { return .autoUIKit } + return detect(for: view) + } +} + +#endif diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift new file mode 100644 index 0000000..4f82c43 --- /dev/null +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/InteractionSwizzler.swift @@ -0,0 +1,916 @@ +// +// InteractionSwizzler.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 12/30/25. +// + +#if canImport(UIKit) && !os(watchOS) + +import UIKit +import Segment +import ObjectiveC + +/// Unified swizzler for all UI interactions. +/// Captures interactions for: +/// - UIControl subclasses: UISwitch, UISlider, UIStepper, UISegmentedControl, +/// UIDatePicker, UIPageControl, UIColorWell, UITextField, UIButton +/// - UIPickerView (delegate-based) +/// - Cell selection: UITableViewCell, UICollectionViewCell +/// - Tab selection: UITabBarController +/// +/// Does NOT emit signals for SwiftUI-backed controls - those are handled by +/// Signal* wrappers (SignalToggle, SignalSlider, etc.) via onChange. +internal class InteractionSwizzler { + static let shared = InteractionSwizzler() + private var controlHandle: Swizzler.SwizzleHandle? + private var tableViewCellHandle: Swizzler.SwizzleHandle? + private var collectionViewCellHandle: Swizzler.SwizzleHandle? + private var tabBarHandle: Swizzler.SwizzleHandle? + private var pickerViewHandle: Swizzler.SwizzleHandle? + @Atomic private var isRunning = false + + private init() {} + + func start() { + guard !isRunning else { return } + _isRunning.set(true) + + // Swizzle UIControl.sendActions(for:) for all control interactions + controlHandle = Swizzler.swizzle( + originalClass: UIControl.self, + originalSelector: #selector(UIControl.sendActions(for:)), + swizzledSelector: #selector(UIControl.signals_sendActions(for:)) + ) + + // Swizzle UITableViewCell.setSelected(_:animated:) for table cell selection + tableViewCellHandle = Swizzler.swizzle( + originalClass: UITableViewCell.self, + originalSelector: #selector(UITableViewCell.setSelected(_:animated:)), + swizzledSelector: #selector(UITableViewCell.signals_setSelected(_:animated:)) + ) + + // Swizzle UICollectionViewCell.isSelected setter for collection cell selection + collectionViewCellHandle = Swizzler.swizzle( + originalClass: UICollectionViewCell.self, + originalSelector: #selector(setter: UICollectionViewCell.isSelected), + swizzledSelector: #selector(UICollectionViewCell.signals_setIsSelected(_:)) + ) + + // Swizzle UITabBarController for tab selection + let tabSelector = Selector(("_setSelectedViewController:")) + tabBarHandle = Swizzler.swizzle( + originalClass: UITabBarController.self, + originalSelector: tabSelector, + swizzledSelector: #selector(UITabBarController.signals_setSelectedViewController(_:)) + ) + + // Swizzle UIPickerView.setDelegate: for picker selection (not available on tvOS) + startPickerViewSwizzling() + } + + func stop() { + if var handle = controlHandle { + handle.restore() + } + controlHandle = nil + + if var handle = tableViewCellHandle { + handle.restore() + } + tableViewCellHandle = nil + + if var handle = collectionViewCellHandle { + handle.restore() + } + collectionViewCellHandle = nil + + if var handle = tabBarHandle { + handle.restore() + } + tabBarHandle = nil + + stopPickerViewSwizzling() + + _isRunning.set(false) + } + + // MARK: - Platform-specific swizzling + + #if os(tvOS) + private func startPickerViewSwizzling() { + // UIPickerView not available on tvOS + } + + private func stopPickerViewSwizzling() { + // UIPickerView not available on tvOS + } + #else + private func startPickerViewSwizzling() { + pickerViewHandle = Swizzler.swizzle( + originalClass: UIPickerView.self, + originalSelector: #selector(setter: UIPickerView.delegate), + swizzledSelector: #selector(UIPickerView.signals_setDelegate(_:)) + ) + } + + private func stopPickerViewSwizzling() { + if var handle = pickerViewHandle { + handle.restore() + } + pickerViewHandle = nil + } + #endif +} + +// MARK: - UIControl Swizzled Methods + +extension UIControl { + @objc dynamic func signals_sendActions(for controlEvents: UIControl.Event) { + // Call original implementation first + signals_sendActions(for: controlEvents) + + // If this is SwiftUI-backed, bail out - Signal* wrappers handle it via onChange + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + // Dispatch based on control type and event + if controlEvents.contains(.valueChanged) { + handleValueChanged() + } + + if controlEvents.contains(.editingDidEnd) { + handleEditingDidEnd() + } + + if controlEvents.contains(.touchUpInside) { + handleTouchUpInside() + } + } + + // MARK: - Value Changed Handlers + + private func handleValueChanged() { + // Try tvOS-unavailable controls first (on non-tvOS platforms) + if handleValueChangedForUnavailableControls() { + return + } + + // These work on all platforms including tvOS + if let segmented = self as? UISegmentedControl { + emitSegmentedControlSignal(segmented) + return + } + if let pageControl = self as? UIPageControl { + emitPageControlSignal(pageControl) + return + } + } + + private func handleEditingDidEnd() { + if let textField = self as? UITextField { + emitTextFieldSignal(textField) + } + } + + private func handleTouchUpInside() { + if let button = self as? UIButton { + emitButtonSignal(button) + } + } + + // MARK: - Platform-specific value changed handling + + #if os(tvOS) + private func handleValueChangedForUnavailableControls() -> Bool { + // UISwitch, UISlider, UIStepper, UIDatePicker, UIColorWell not available on tvOS + return false + } + #else + private func handleValueChangedForUnavailableControls() -> Bool { + if let toggle = self as? UISwitch { + emitToggleSignal(toggle) + return true + } + if let slider = self as? UISlider { + emitSliderSignal(slider) + return true + } + if let stepper = self as? UIStepper { + emitStepperSignal(stepper) + return true + } + if let datePicker = self as? UIDatePicker { + emitDatePickerSignal(datePicker) + return true + } + if #available(iOS 14.0, macCatalyst 14.0, *) { + if let colorWell = self as? UIColorWell { + emitColorWellSignal(colorWell) + return true + } + } + return false + } + #endif + + // MARK: - Signal Emitters (tvOS-unavailable controls) + + #if !os(tvOS) + private func emitToggleSignal(_ toggle: UISwitch) { + let title = extractTitle() ?? toggle.accessibilityLabel + var data: [String: Any] = [ + "action": "toggled", + "value": toggle.isOn + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Toggle", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitSliderSignal(_ slider: UISlider) { + let title = extractTitle() ?? slider.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "value": slider.value, + "minimum": slider.minimumValue, + "maximum": slider.maximumValue + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Slider", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitStepperSignal(_ stepper: UIStepper) { + let title = extractTitle() ?? stepper.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "value": stepper.value, + "minimum": stepper.minimumValue, + "maximum": stepper.maximumValue, + "step": stepper.stepValue + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Stepper", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitDatePickerSignal(_ datePicker: UIDatePicker) { + let title = extractTitle() ?? datePicker.accessibilityLabel + let formatter = ISO8601DateFormatter() + + var data: [String: Any] = [ + "action": "changed", + "value": formatter.string(from: datePicker.date), + "mode": datePickerModeString(datePicker.datePickerMode) + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "DatePicker", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + @available(iOS 14.0, macCatalyst 14.0, *) + private func emitColorWellSignal(_ colorWell: UIColorWell) { + let title = extractTitle() ?? colorWell.accessibilityLabel + var data: [String: Any] = [ + "action": "changed" + ] + + if let color = colorWell.selectedColor { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + data["value"] = [ + "red": red, + "green": green, + "blue": blue, + "alpha": alpha + ] + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "ColorPicker", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func datePickerModeString(_ mode: UIDatePicker.Mode) -> String { + switch mode { + case .time: return "time" + case .date: return "date" + case .dateAndTime: return "dateAndTime" + case .countDownTimer: return "countDownTimer" + case .yearAndMonth: return "yearAndMonth" + @unknown default: return "unknown" + } + } + #endif + + // MARK: - Signal Emitters (all platforms) + + private func emitButtonSignal(_ button: UIButton) { + let title = extractTitle() ?? button.accessibilityLabel ?? button.currentTitle + var data: [String: Any] = [ + "action": "tap" + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "Button", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitSegmentedControlSignal(_ segmented: UISegmentedControl) { + let title = extractTitle() ?? segmented.accessibilityLabel + let selectedTitle = segmented.titleForSegment(at: segmented.selectedSegmentIndex) + + var data: [String: Any] = [ + "action": "selected", + "selectedIndex": segmented.selectedSegmentIndex, + "segmentCount": segmented.numberOfSegments + ] + if let selectedTitle = selectedTitle { + data["selectedTitle"] = selectedTitle + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "SegmentedControl", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitPageControlSignal(_ pageControl: UIPageControl) { + let title = extractTitle() ?? pageControl.accessibilityLabel + var data: [String: Any] = [ + "action": "changed", + "currentPage": pageControl.currentPage, + "numberOfPages": pageControl.numberOfPages + ] + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: "PageControl", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func emitTextFieldSignal(_ textField: UITextField) { + let title = extractTitle() ?? textField.accessibilityLabel ?? textField.placeholder + + var data: [String: Any] = [ + "action": "edited", + "secure": textField.isSecureTextEntry + ] + + // Don't include actual text for secure fields - just metadata + if textField.isSecureTextEntry { + data["value"] = "" // Never include secure text + } + data["isEmpty"] = textField.text?.isEmpty ?? true + data["characterCount"] = textField.text?.count ?? 0 + + if let placeholder = textField.placeholder, !placeholder.isEmpty { + data["placeholder"] = placeholder + } + addAccessibilityInfo(to: &data) + + let signal = InteractionSignal( + component: textField.isSecureTextEntry ? "SecureField" : "TextField", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + // MARK: - Helpers + + private func addAccessibilityInfo(to data: inout [String: Any]) { + if let label = accessibilityLabel, !label.isEmpty { + data["accessibilityLabel"] = label + } + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + } + + /// Attempts to extract a meaningful title for the control + fileprivate func extractTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Try accessibility identifier + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + return identifier + } + + // Walk up to find a cell or container, then search for labels + if let title = findLabelInContainer() { + return title + } + + return nil + } + + /// Walks up the hierarchy to find a container, then searches for labels within it + private func findLabelInContainer() -> String? { + var current: UIView? = superview + + while let view = current { + let typeName = String(describing: type(of: view)) + + // Stop at cell boundaries or common containers + if typeName.contains("Cell") || + typeName.contains("TableViewCell") || + typeName.contains("CollectionViewCell") { + return findFirstLabel(in: view, excluding: self) + } + + current = view.superview + } + + return nil + } + + /// Recursively searches a view hierarchy for the first UILabel with text + fileprivate func findFirstLabel(in view: UIView, excluding: UIView) -> String? { + if view === excluding { return nil } + + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview, excluding: excluding) { + return found + } + } + + return nil + } +} + +// MARK: - UITableViewCell Swizzled Methods + +extension UITableViewCell { + @objc dynamic func signals_setSelected(_ selected: Bool, animated: Bool) { + // Call original implementation first + signals_setSelected(selected, animated: animated) + + // Only emit on selection, not deselection + guard selected else { return } + + // If this is SwiftUI-backed, bail out + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + let title = extractCellTitle() + var data: [String: Any] = [ + "action": "selected", + "isEditing": isEditing, + "showingDeleteConfirmation": showingDeleteConfirmation + ] + + // Try to get index path from parent table view + if let tableView = findParentTableView(), + let indexPath = tableView.indexPath(for: self) { + data["section"] = indexPath.section + data["row"] = indexPath.row + } + + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + + let signal = InteractionSignal( + component: "TableViewCell", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func extractCellTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Try textLabel (standard cell) + if let text = textLabel?.text, !text.isEmpty { + return text + } + + // Find first UILabel in hierarchy + return findFirstLabel(in: contentView) + } + + private func findFirstLabel(in view: UIView) -> String? { + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview) { + return found + } + } + + return nil + } + + private func findParentTableView() -> UITableView? { + var current: UIView? = superview + while let view = current { + if let tableView = view as? UITableView { + return tableView + } + current = view.superview + } + return nil + } +} + +// MARK: - UICollectionViewCell Swizzled Methods + +extension UICollectionViewCell { + @objc dynamic func signals_setIsSelected(_ selected: Bool) { + // Call original implementation first + signals_setIsSelected(selected) + + // Only emit on selection, not deselection + guard selected else { return } + + // If this is SwiftUI-backed, bail out + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + return + } + + let title = extractCellTitle() + var data: [String: Any] = [ + "action": "selected" + ] + + // Try to get index path from parent collection view + if let collectionView = findParentCollectionView(), + let indexPath = collectionView.indexPath(for: self) { + data["section"] = indexPath.section + data["item"] = indexPath.item + } + + if let identifier = accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + + let signal = InteractionSignal( + component: "CollectionViewCell", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func extractCellTitle() -> String? { + // Check accessibility label first + if let label = accessibilityLabel, !label.isEmpty { + return label + } + + // Find first UILabel in hierarchy + return findFirstLabel(in: contentView) + } + + private func findFirstLabel(in view: UIView) -> String? { + if let label = view as? UILabel, + let text = label.text, + !text.isEmpty { + return text + } + + for subview in view.subviews { + if let found = findFirstLabel(in: subview) { + return found + } + } + + return nil + } + + private func findParentCollectionView() -> UICollectionView? { + var current: UIView? = superview + while let view = current { + if let collectionView = view as? UICollectionView { + return collectionView + } + current = view.superview + } + return nil + } +} + +// MARK: - UITabBarController Swizzled Methods + +extension UITabBarController { + @objc dynamic func signals_setSelectedViewController(_ viewController: UIViewController?) { + // Get index before we call original + var oldIndex = selectedIndex + // On first time through, it's some giant uninitialized value + // If it's over 100, assume we're at initial state + if oldIndex > 100 { oldIndex = 0 } + let newIndex = viewControllers?.firstIndex(of: viewController ?? UIViewController()) ?? NSNotFound + + // Call original implementation + self.signals_setSelectedViewController(viewController) + + // Only emit if we have a valid index change + if newIndex != NSNotFound && newIndex != oldIndex { + var data: [String: Any] = [ + "action": "tabSelected", + "previousTab": oldIndex, + "selectedTab": newIndex + ] + + // Try to get tab names if available + if let items = tabBar.items { + if newIndex < items.count { + let newItem = items[newIndex] + if let title = newItem.accessibilityLabel ?? newItem.title { + data["selectedTabName"] = title + } + } + + if oldIndex < items.count { + let oldItem = items[oldIndex] + if let title = oldItem.accessibilityLabel ?? oldItem.title { + data["previousTabName"] = title + } + } + } + + // Detect if this is SwiftUI TabView or UIKit UITabBarController + let source = detectSource() + + let signal = InteractionSignal( + component: "TabBar", + title: nil, + data: data + ) + Signals.emit(signal: signal, source: source) + } + } + + /// Detects if this UITabBarController is hosted by SwiftUI + private func detectSource() -> SignalSource { + // First check the tabBarController's own view hierarchy + if let source = checkViewHierarchy(view) { + return source + } + + // Also check if any child VCs are SwiftUI hosting controllers + for vc in viewControllers ?? [] { + let className = String(describing: type(of: vc)) + if className.contains("HostingController") { + return .autoSwiftUI + } + } + + return .autoUIKit + } + + private func checkViewHierarchy(_ view: UIView?) -> SignalSource? { + var current: UIView? = view?.superview + + while let v = current { + let typeName = String(describing: type(of: v)) + + if typeName.contains("UIKitPlatformViewHost") || + typeName.contains("PlatformViewRepresentableAdaptor") || + typeName.contains("HostingView") { + return .autoSwiftUI + } + + current = v.superview + } + + return nil + } +} + +// MARK: - UIPickerView Swizzled Methods & Delegate Proxy (not available on tvOS) + +#if !os(tvOS) + +/// Storage for original delegates (weak references) +private var originalDelegateKey: UInt8 = 0 + +extension UIPickerView { + @objc dynamic func signals_setDelegate(_ delegate: UIPickerViewDelegate?) { + if let delegate = delegate { + // Check if this is SwiftUI-backed + let source = ControlSignalSource.detect(for: self) + if source == .autoSwiftUI { + // Don't wrap SwiftUI delegates - let SignalPicker handle it + signals_setDelegate(delegate) + return + } + + // Create proxy that wraps the original delegate + let proxy = PickerViewDelegateProxy(originalDelegate: delegate, pickerView: self) + + // Store reference to keep proxy alive (associated object) + objc_setAssociatedObject(self, &originalDelegateKey, proxy, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + // Set proxy as delegate + signals_setDelegate(proxy) + } else { + // Clear associated proxy when delegate is nil + objc_setAssociatedObject(self, &originalDelegateKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + signals_setDelegate(nil) + } + } +} + +/// Proxy that intercepts UIPickerViewDelegate calls to emit signals +private class PickerViewDelegateProxy: NSObject, UIPickerViewDelegate, UIPickerViewDataSource { + weak var originalDelegate: UIPickerViewDelegate? + weak var originalDataSource: UIPickerViewDataSource? + weak var pickerView: UIPickerView? + + init(originalDelegate: UIPickerViewDelegate, pickerView: UIPickerView) { + self.originalDelegate = originalDelegate + self.originalDataSource = originalDelegate as? UIPickerViewDataSource + self.pickerView = pickerView + super.init() + } + + // MARK: - Signal Emission + + private func emitPickerSignal(row: Int, component: Int) { + guard let pickerView = pickerView else { return } + + let title = extractTitle(for: pickerView) + var data: [String: Any] = [ + "action": "selected", + "selectedRow": row, + "component": component, + "numberOfComponents": pickerView.numberOfComponents + ] + + // Try to get the title of the selected row + if let rowTitle = originalDelegate?.pickerView?(pickerView, titleForRow: row, forComponent: component) { + data["selectedTitle"] = rowTitle + } else if let attributedTitle = originalDelegate?.pickerView?(pickerView, attributedTitleForRow: row, forComponent: component) { + data["selectedTitle"] = attributedTitle.string + } + + // Add accessibility info + if let label = pickerView.accessibilityLabel, !label.isEmpty { + data["accessibilityLabel"] = label + } + if let identifier = pickerView.accessibilityIdentifier, !identifier.isEmpty { + data["accessibilityIdentifier"] = identifier + } + + let signal = InteractionSignal( + component: "Picker", + title: title, + data: data + ) + Signals.emit(signal: signal, source: .autoUIKit) + } + + private func extractTitle(for pickerView: UIPickerView) -> String? { + if let label = pickerView.accessibilityLabel, !label.isEmpty { + return label + } + if let identifier = pickerView.accessibilityIdentifier, !identifier.isEmpty { + return identifier + } + // Try to find a label in the superview hierarchy + return findLabelInSuperview(of: pickerView) + } + + private func findLabelInSuperview(of view: UIView) -> String? { + var current: UIView? = view.superview + while let parent = current { + // Look for labels that might be titles for this picker + for subview in parent.subviews { + if let label = subview as? UILabel, + let text = label.text, + !text.isEmpty, + subview !== view { + return text + } + } + current = parent.superview + } + return nil + } + + // MARK: - UIPickerViewDelegate (Intercepted) + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + // Emit signal first + emitPickerSignal(row: row, component: component) + + // Forward to original delegate + originalDelegate?.pickerView?(pickerView, didSelectRow: row, inComponent: component) + } + + // MARK: - UIPickerViewDelegate (Forwarded) + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return originalDelegate?.pickerView?(pickerView, titleForRow: row, forComponent: component) + } + + func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { + return originalDelegate?.pickerView?(pickerView, attributedTitleForRow: row, forComponent: component) + } + + func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView { + // This is required if the original delegate implements it + if let view = originalDelegate?.pickerView?(pickerView, viewForRow: row, forComponent: component, reusing: view) { + return view + } + // Fallback - return empty view (shouldn't happen if delegate implements titleForRow) + return UIView() + } + + func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { + return originalDelegate?.pickerView?(pickerView, rowHeightForComponent: component) ?? 44.0 + } + + func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat { + return originalDelegate?.pickerView?(pickerView, widthForComponent: component) ?? 0 + } + + // MARK: - UIPickerViewDataSource (Forwarded) + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return originalDataSource?.numberOfComponents(in: pickerView) ?? 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return originalDataSource?.pickerView(pickerView, numberOfRowsInComponent: component) ?? 0 + } + + // MARK: - Message Forwarding for Other Methods + + override func responds(to aSelector: Selector!) -> Bool { + if super.responds(to: aSelector) { + return true + } + return originalDelegate?.responds(to: aSelector) ?? false + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + if originalDelegate?.responds(to: aSelector) == true { + return originalDelegate + } + return super.forwardingTarget(for: aSelector) + } +} + +#endif // !os(tvOS) + +#endif // canImport(UIKit) && !os(watchOS) diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/UIKit/Swizzler.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Controls/Swizzler.swift similarity index 100% rename from Sources/AnalyticsLive/Signals/AutoTracking/UIKit/Swizzler.swift rename to Sources/AnalyticsLive/Signals/AutoTracking/Controls/Swizzler.swift diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift new file mode 100644 index 0000000..e5a82fe --- /dev/null +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Navigation/NavigationObserver.swift @@ -0,0 +1,402 @@ +// +// NavigationObserver.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 12/30/25. +// + +#if canImport(UIKit) && !os(watchOS) + +import UIKit +import Segment + +/// Observes navigation events via UIViewController lifecycle swizzling. +/// Works with both UIKit and SwiftUI apps - no wrapper types needed. +public class NavigationObserver { + public static let shared = NavigationObserver() + + private init() {} + + /// Stack of screens that are "covered" by modals but not gone + private var coveredScreens: [ScreenInfo] = [] + + /// Track the current visible screen so we know what's being covered + private var currentScreen: ScreenInfo? + + /// Whether swizzling is active + @Atomic private var isRunning = false + + /// Enable debug logging to console + public var debugLogging = false + + public func start() { + guard !isRunning else { return } + _isRunning.set(true) + + // Lifecycle swizzles + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.viewDidAppear(_:)), + swizzled: #selector(UIViewController.signals_viewDidAppear(_:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.viewWillDisappear(_:)), + swizzled: #selector(UIViewController.signals_viewWillDisappear(_:)) + ) + + // Presentation swizzles + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.present(_:animated:completion:)), + swizzled: #selector(UIViewController.signals_present(_:animated:completion:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.dismiss(animated:completion:)), + swizzled: #selector(UIViewController.signals_dismiss(animated:completion:)) + ) + + log("🔧 NavigationObserver: Started") + } + + public func stop() { + guard isRunning else { return } + + // Swap back to originals + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_viewDidAppear(_:)), + swizzled: #selector(UIViewController.viewDidAppear(_:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_viewWillDisappear(_:)), + swizzled: #selector(UIViewController.viewWillDisappear(_:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_present(_:animated:completion:)), + swizzled: #selector(UIViewController.present(_:animated:completion:)) + ) + swizzle( + cls: UIViewController.self, + original: #selector(UIViewController.signals_dismiss(animated:completion:)), + swizzled: #selector(UIViewController.dismiss(animated:completion:)) + ) + + _isRunning.set(false) + coveredScreens.removeAll() + currentScreen = nil + + log("🔧 NavigationObserver: Stopped") + } + + private func swizzle(cls: AnyClass, original: Selector, swizzled: Selector) { + guard let originalMethod = class_getInstanceMethod(cls, original), + let swizzledMethod = class_getInstanceMethod(cls, swizzled) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + // MARK: - Screen Tracking + + func screenAppeared(_ vc: UIViewController) { + let info = ScreenInfo.extract(from: vc) + + // Filter out container noise + guard !info.isContainerNoise else { return } + + let prev = currentScreen + currentScreen = info + + // Emit navigation signal with full screen data + let source: SignalSource = info.isSwiftUI ? .autoSwiftUI : .autoUIKit + let signal = NavigationSignal( + currentScreen: info.toScreenData(), + previousScreen: prev?.toScreenData() + ) + Signals.emit(signal: signal, source: source) + + log("📱 ENTERED: \(info.bestName)") + } + + func screenDisappearing(_ vc: UIViewController) { + let info = ScreenInfo.extract(from: vc) + + // Filter out container noise + guard !info.isContainerNoise else { return } + + log("👋 LEFT: \(info.bestName)") + } + + func modalWillPresent(from presentingVC: UIViewController, to presentedVC: UIViewController) { + // Use currentScreen - we already know what's visible! + if let coveredInfo = currentScreen { + coveredScreens.append(coveredInfo) + log("📥 COVERED: \(coveredInfo.bestName)") + } + + // Set up dismissal detection for swipe-to-dismiss + if let presentationController = presentedVC.presentationController { + let detector = DismissalDetector(observer: self) + presentationController.delegate = detector + // Store it so it doesn't get deallocated + objc_setAssociatedObject(presentedVC, &AssociatedKeys.dismissalDetector, detector, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func modalDidDismiss() { + returnToCoveredScreenIfNeeded() + } + + private func returnToCoveredScreenIfNeeded() { + guard let returnTo = coveredScreens.popLast() else { return } + + let prev = currentScreen + currentScreen = returnTo + + // Emit navigation signal for returning to covered screen with full data + let source: SignalSource = returnTo.isSwiftUI ? .autoSwiftUI : .autoUIKit + let signal = NavigationSignal( + currentScreen: returnTo.toScreenData(), + previousScreen: prev?.toScreenData() + ) + Signals.emit(signal: signal, source: source) + + log("🔙 RETURNED TO: \(returnTo.bestName)") + } + + private func log(_ message: String) { + if debugLogging { + print(message) + } + } +} + +// MARK: - Associated Object Keys + +private struct AssociatedKeys { + static var dismissalDetector: UInt8 = 0 +} + +// MARK: - Dismissal Detector (for swipe-to-dismiss) + +class DismissalDetector: NSObject, UIAdaptivePresentationControllerDelegate { + weak var observer: NavigationObserver? + + init(observer: NavigationObserver) { + self.observer = observer + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + observer?.modalDidDismiss() + } +} + +// MARK: - UIViewController Swizzled Methods + +extension UIViewController { + @objc func signals_viewDidAppear(_ animated: Bool) { + // Call original implementation + signals_viewDidAppear(animated) + NavigationObserver.shared.screenAppeared(self) + } + + @objc func signals_viewWillDisappear(_ animated: Bool) { + // Call original implementation + signals_viewWillDisappear(animated) + NavigationObserver.shared.screenDisappearing(self) + } + + @objc func signals_present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + // Notify observer before presenting + NavigationObserver.shared.modalWillPresent(from: self, to: viewControllerToPresent) + + // Call original implementation + signals_present(viewControllerToPresent, animated: flag, completion: completion) + } + + @objc func signals_dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + let hasPresenting = self.presentingViewController != nil + let hasPresented = self.presentedViewController != nil + + // Call original implementation + signals_dismiss(animated: flag, completion: { + completion?() + // Fire if this VC was presented or was presenting something + if hasPresenting || hasPresented { + NavigationObserver.shared.modalDidDismiss() + } + }) + } +} + +// MARK: - Screen Info + +/// Extracts useful info from a UIViewController for screen identification +struct ScreenInfo { + let className: String + let title: String? + let navItemTitle: String? + let swiftUIViewName: String? + let accessibilityLabel: String? + let accessibilityIdentifier: String? + let isSwiftUI: Bool + + /// Is this just a container VC that we should ignore? + var isContainerNoise: Bool { + let noiseClasses = [ + // UIKit containers + "UINavigationController", + "UITabBarController", + // SwiftUI internal containers + "UIKitTabBarController", + "UIKitNavigationController", + "TabHostingController", + "UITrackingElementWindowController", + "PresentationHostingController", + "UIInputWindowController", + "UIEditingOverlayViewController", + // Keyboard-related VCs + "_UICursorAccessoryViewController", + "UICompatibilityInputViewController", + "UISystemInputAssistantViewController", + "UIPredictionViewController", + "UISystemKeyboardDockController", + "UIEditingOverlayViewController", + "UIKeyboardHiddenViewController" + ] + + // Direct class name match + if noiseClasses.contains(className) { + return true + } + + // Filter private Apple VCs (start with _UI) + if className.hasPrefix("_UI") { + return true + } + + // Filter system keyboard/input related VCs + if className.hasPrefix("UISystem") && (className.contains("Keyboard") || className.contains("Input")) { + return true + } + + // Root hosting controller (not NavigationStack content) + if className.hasPrefix("UIHostingController<") && + className.contains("RootModifier") { + return true + } + + return false + } + + /// Best effort at a meaningful screen name + var bestName: String { + // Priority order: + // 1. Navigation item title (set via .navigationTitle() in SwiftUI) + // 2. View controller title + // 3. Accessibility label + // 4. Accessibility identifier + // 5. Extracted SwiftUI view name from generic soup + // 6. Raw class name + if let navTitle = navItemTitle, !navTitle.isEmpty { + return navTitle + } + if let title = title, !title.isEmpty { + return title + } + if let accLabel = accessibilityLabel, !accLabel.isEmpty { + return accLabel + } + if let accId = accessibilityIdentifier, !accId.isEmpty { + return accId + } + if let swiftUIName = swiftUIViewName, swiftUIName != "AnyView" { + return swiftUIName + } + return className + } + + static func extract(from vc: UIViewController) -> ScreenInfo { + let className = String(describing: type(of: vc)) + let swiftUIName = extractSwiftUIViewName(from: className) + let isSwiftUI = className.contains("HostingController") + + return ScreenInfo( + className: className, + title: vc.title, + navItemTitle: vc.navigationItem.title, + swiftUIViewName: swiftUIName, + accessibilityLabel: vc.view?.accessibilityLabel, + accessibilityIdentifier: vc.view?.accessibilityIdentifier, + isSwiftUI: isSwiftUI + ) + } + + /// Convert to NavigationSignal.ScreenData for signal emission + func toScreenData() -> NavigationSignal.ScreenData { + return NavigationSignal.ScreenData( + name: bestName, + className: className, + title: title, + navTitle: navItemTitle, + swiftUIViewName: swiftUIViewName, + accessibilityLabel: accessibilityLabel, + accessibilityIdentifier: accessibilityIdentifier + ) + } + + /// Try to extract a SwiftUI view name from UIHostingController's generic type + private static func extractSwiftUIViewName(from className: String) -> String? { + guard className.contains("HostingController") || className.contains("Hosting") else { + return nil + } + + guard let startIndex = className.firstIndex(of: "<"), + let endIndex = className.lastIndex(of: ">") else { + return nil + } + + let genericContent = String(className[className.index(after: startIndex)..", with: ",") + .replacingOccurrences(of: "<", with: ",") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + + for component in components { + // Skip internal SwiftUI types + if component.hasPrefix("_") || + component.contains("Modifier") || + component.contains("Content") || + component == "Never" || + component == "Text" || + component == "Optional" || + component == "AnyView" { + continue + } + + if component.hasSuffix("View") { + return component + } + + if component.first?.isUppercase == true && + !component.contains(".") && + component.count > 2 { + return component + } + } + + if genericContent.contains("AnyView") { + return "AnyView" + } + + return nil + } +} + +#endif diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Networking/SignalsNetworkTracking.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Networking/SignalsNetworkTracking.swift old mode 100755 new mode 100644 index cb92559..a1d457f --- a/Sources/AnalyticsLive/Signals/AutoTracking/Networking/SignalsNetworkTracking.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Networking/SignalsNetworkTracking.swift @@ -23,7 +23,10 @@ class SignalsNetworkTracking: UtilityPlugin { } internal func setupNetworkHook() { + // Catches URLSession.shared and basic default configs URLProtocol.registerClass(SignalsNetworkProtocol.self) + // Catches custom configurations (Alamofire, etc.) + URLSessionConfigurationSwizzler.shared.start() } public func execute(event: T?) -> T? { diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/Networking/URLSessionConfigurationSwizzler.swift b/Sources/AnalyticsLive/Signals/AutoTracking/Networking/URLSessionConfigurationSwizzler.swift new file mode 100644 index 0000000..aef6268 --- /dev/null +++ b/Sources/AnalyticsLive/Signals/AutoTracking/Networking/URLSessionConfigurationSwizzler.swift @@ -0,0 +1,63 @@ +// +// URLSessionConfigurationSwizzler.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 1/2/26. +// + +import Foundation + +/// Swizzles URLSessionConfiguration.protocolClasses to automatically inject +/// SignalsNetworkProtocol into ALL URLSession configurations, not just the shared +/// session or default configurations. +/// +/// This enables automatic network tracking for third-party networking libraries +/// like Alamofire that create custom URLSessionConfigurations. +class URLSessionConfigurationSwizzler { + static let shared = URLSessionConfigurationSwizzler() + private var hasSwizzled = false + + func start() { + guard !hasSwizzled else { return } + hasSwizzled = true + + let originalSelector = #selector(getter: URLSessionConfiguration.protocolClasses) + let swizzledSelector = #selector(getter: URLSessionConfiguration.signals_protocolClasses) + + guard let originalMethod = class_getInstanceMethod(URLSessionConfiguration.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(URLSessionConfiguration.self, swizzledSelector) else { + return + } + + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + func stop() { + guard hasSwizzled else { return } + hasSwizzled = false + + let originalSelector = #selector(getter: URLSessionConfiguration.protocolClasses) + let swizzledSelector = #selector(getter: URLSessionConfiguration.signals_protocolClasses) + + guard let originalMethod = class_getInstanceMethod(URLSessionConfiguration.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(URLSessionConfiguration.self, swizzledSelector) else { + return + } + + method_exchangeImplementations(originalMethod, swizzledMethod) + } +} + +extension URLSessionConfiguration { + @objc dynamic var signals_protocolClasses: [AnyClass]? { + // After swizzling, this call actually invokes the ORIGINAL getter + var protocols = self.signals_protocolClasses ?? [] + + // Don't double-inject if already present + if !protocols.contains(where: { $0 == SignalsNetworkProtocol.self }) { + protocols.insert(SignalsNetworkProtocol.self, at: 0) + } + + return protocols + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Annotations.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Annotations.swift old mode 100755 new mode 100644 index 991a125..1b14b79 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Annotations.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Annotations.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// Annotations.swift +// AnalyticsLive // // Created by Brandon Sneed on 2/20/24. // @@ -9,27 +9,95 @@ import Foundation import SwiftUI import Segment +// MARK: - View Utilities + extension View { + /// Returns the struct name of the View type. static func structName() -> String { String(describing: Self.self) } } +// MARK: - SignalAnnotation + +/// A view modifier that attaches a text annotation to a view for signal capture. +/// +/// `SignalAnnotation` allows you to provide explicit labels for views that would otherwise +/// be difficult to identify automatically. The annotation text is extracted during signal +/// processing to provide meaningful titles for interaction signals. +/// +/// You typically don't create `SignalAnnotation` directly. Instead, use the +/// `signalAnnotation(_:)` view modifier methods. +/// +/// ## Example +/// ```swift +/// // Simple annotation +/// Button("Submit") { submitForm() } +/// .signalAnnotation("Submit Order Form") +/// +/// // Dynamic annotation based on state +/// Toggle(isOn: $isEnabled) { Text("Enable Feature") } +/// .signalAnnotation(state: isEnabled, true: "Feature Enabled", false: "Feature Disabled") +/// +/// // Custom annotation from state +/// Slider(value: $volume) +/// .signalAnnotation(state: volume) { "Volume: \(Int($0 * 100))%" } +/// ``` public struct SignalAnnotation: ViewModifier { let text: String + init(_ text: String) { self.text = text } + public func body(content: Content) -> some View { content } } +// MARK: - View Extensions + extension View { + /// Attaches a signal annotation to this view. + /// + /// Use this modifier to provide an explicit label for signal capture when the + /// automatic label extraction doesn't produce a meaningful result. + /// + /// - Parameter text: The annotation text to attach to this view. + /// - Returns: A view with the signal annotation attached. + /// + /// ## Example + /// ```swift + /// Button(action: { checkout() }) { + /// HStack { + /// Image(systemName: "cart") + /// Text("Buy Now") + /// } + /// } + /// .signalAnnotation("Checkout Button") + /// ``` public func signalAnnotation(_ text: String) -> some View { return modifier(SignalAnnotation(text)) } + /// Attaches a signal annotation based on a boolean state. + /// + /// Use this modifier when you want different annotation text depending on + /// a boolean condition. + /// + /// - Parameters: + /// - state: The boolean state to evaluate. + /// - trueText: The annotation text when `state` is `true`. + /// - falseText: The annotation text when `state` is `false`. + /// - Returns: A view with the conditional signal annotation attached. + /// + /// ## Example + /// ```swift + /// Button(action: { toggleFavorite() }) { + /// Image(systemName: isFavorite ? "heart.fill" : "heart") + /// } + /// .signalAnnotation(state: isFavorite, true: "Remove Favorite", false: "Add Favorite") + /// ``` public func signalAnnotation(state: Bool, true trueText: String, false falseText: String) -> some View { if state { return modifier(SignalAnnotation(trueText)) @@ -38,11 +106,25 @@ extension View { } } + /// Attaches a signal annotation derived from a state value. + /// + /// Use this modifier when you need to generate annotation text dynamically + /// based on some state value. + /// + /// - Parameters: + /// - state: The state value to use for generating the annotation. + /// - text: A closure that takes the state value and returns the annotation text. + /// - Returns: A view with the dynamic signal annotation attached. + /// + /// ## Example + /// ```swift + /// Stepper(value: $quantity, in: 1...10) { + /// Text("Quantity: \(quantity)") + /// } + /// .signalAnnotation(state: quantity) { "Set Quantity to \($0)" } + /// ``` public func signalAnnotation(state: T, text: (T) -> String) -> some View { let t = text(state) return modifier(SignalAnnotation(t)) } } - - - diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Description.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Description.swift old mode 100755 new mode 100644 index 4a38773..b54ac18 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Description.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/Description.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// Description.swift +// AnalyticsLive // // Created by Brandon Sneed on 2/21/24. // @@ -8,6 +8,24 @@ import Foundation import SwiftUI +// MARK: - SignalingUI Protocol + +/// Protocol for SwiftUI wrapper types that emit interaction signals. +/// +/// Conforming types provide a `controlType()` method that returns a string +/// identifier for the type of control (e.g., "Button", "Toggle", "Slider"). +/// This is used when emitting `InteractionSignal` events. +public protocol SignalingUI { + /// Returns the control type identifier for this signal-emitting view. + static func controlType() -> String +} + +// MARK: - Label Description Functions + +/// Extracts a label string from a stringified view description. +/// +/// Handles both `SignalAnnotation` wrapped labels and plain text labels +/// by parsing the string representation of a SwiftUI view. internal func describe(label: String) -> String? { let s = label if let r = s.range(of: "Signals.SignalAnnotation(text:") { @@ -20,11 +38,13 @@ internal func describe(label: String) -> String? { return label } +/// Extracts a label string from any value by converting it to a string first. internal func describe(label: Any?) -> String? { if label == nil { return nil } return describe(label: String(describing: label)) } +/// Attempts to extract a label from multiple optional values, returning the first successful match. internal func describeWith(options: [Any?]) -> String? { var result: String? = nil for opt in options { @@ -36,3 +56,42 @@ internal func describeWith(options: [Any?]) -> String? { } return result } + +// MARK: - LocalizedStringKey Extension + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension LocalizedStringKey { + /// Extracts the key string from a LocalizedStringKey using reflection. + /// + /// This is a workaround since `LocalizedStringKey` doesn't expose its underlying key directly. + internal var string: String { + Mirror(reflecting: self).children.first(where: { $0.label == "key" })?.value as? String ?? "unknown" + } +} + +// MARK: - LocalizedStringResource Extension + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension LocalizedStringResource { + /// Extracts the localized string from a LocalizedStringResource. + internal var string: String { + String(localized: self) + } +} + +// MARK: - View Label Extraction + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension View { + /// Extracts a label string from a View using reflection and description parsing. + /// + /// This method converts the view to its string description and attempts to parse + /// out a meaningful label, either from a `SignalAnnotation` or from text content. + internal static func extractLabel(_ label: V) -> String { + let s = String(describing: label) + if let label = describe(label: s) { + return label + } + return "Unknown Label" + } +} diff --git a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalButton.swift b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalButton.swift old mode 100755 new mode 100644 index 60ff6e0..1e28e30 --- a/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalButton.swift +++ b/Sources/AnalyticsLive/Signals/AutoTracking/SwiftUI/SignalButton.swift @@ -1,6 +1,6 @@ // // SignalButton.swift -// SegmentShop +// AnalyticsLive // // Created by Brandon Sneed on 2/1/24. // @@ -8,77 +8,32 @@ import Foundation import SwiftUI -extension LocalizedStringKey { - // I don't know how palatable this is. - var string: String { - Mirror(reflecting: self).children.first(where: { $0.label == "key" })?.value as? String ?? "unknown" - } -} - -extension View { - static func extractLabel(_ label: V, _ file: String? = nil, _ function: String? = nil, _ line: Int? = nil) -> String { - let s = String(describing: label) - var result: String - - let label = describe(label: s) - if let label { - result = label - } else { - if let file, let function, let line { - result = "Unknown Label @ \(file), \(function), line \(line)" - } else { - result = "Unknown Label" - } - } - return result - } -} - -public protocol SignalingUI { - static func controlType() -> String -} +// MARK: - SignalButton @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public struct SignalButton