diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index 6854599..6a60d20 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -15,7 +15,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } # ── Args (forwarded as-is to run-local.sh) ──────────────────────────────────── -ALL_SDKS=(android cordova capacitor dotnet expo flutter react-native unity) +ALL_SDKS=(android cordova capacitor dotnet expo flutter ios react-native unity) EXTRA_ARGS=() PLATFORM_FILTER="" @@ -42,14 +42,16 @@ Usage: $0 [OPTIONS] Runs the Appium E2E suite across every SDK/platform combo by delegating to run-local.sh. Combos: cordova, capacitor, react-native, flutter, dotnet, -expo, unity on ios + android, plus android (native) on android only. +expo, unity on ios + android, plus android (native) on android only and +ios (native) on ios only. Options: --platform=ios|android Only run combos for the given platform (default: both) --sdks=LIST Comma-separated SDKs to run (default: all) Valid: cordova, capacitor, react-native, flutter, - dotnet, expo, unity, android - Note: 'android' (native) skips --platform=ios. + dotnet, expo, unity, android, ios + Note: 'android' (native) skips --platform=ios and + 'ios' (native) skips --platform=android. --bail Stop after the first failing combo Options forwarded to run-local.sh: @@ -95,19 +97,34 @@ declare -a RESULTS FAILED=0 BAILED=0 SKIPPED=0 +BAIL_OUT=0 for platform in "${PLATFORMS[@]}"; do for sdk in "${SDKS[@]}"; do - # Native Android demo only exists for Android. + # Native demos only target their own platform, so the platform suffix + # is redundant — keep the summary table consistent by using the short + # label in all branches (SKIP, PASS, FAIL). + if [[ "$sdk" == "ios" || "$sdk" == "android" ]]; then + label="${sdk}" + else + label="${sdk} / ${platform}" + fi if [[ "$sdk" == "android" && "$platform" == "ios" ]]; then if [[ -n "$PLATFORM_FILTER" ]]; then warn "--sdk=android only runs on --platform=android; skipping --platform=ios" - RESULTS+=("SKIP ${sdk} / ${platform}") + RESULTS+=("SKIP ${label}") + SKIPPED=$((SKIPPED + 1)) + fi + continue + fi + if [[ "$sdk" == "ios" && "$platform" == "android" ]]; then + if [[ -n "$PLATFORM_FILTER" ]]; then + warn "--sdk=ios only runs on --platform=ios; skipping --platform=android" + RESULTS+=("SKIP ${label}") SKIPPED=$((SKIPPED + 1)) fi continue fi - label="${sdk} / ${platform}" echo "" echo -e "${BOLD}━━━ Running: ${label} ━━━${NC}" # `${arr[@]+"${arr[@]}"}` expands the array only when it has elements; @@ -119,11 +136,13 @@ for platform in "${PLATFORMS[@]}"; do FAILED=$((FAILED + 1)) if (( BAIL )); then BAILED=1 + BAIL_OUT=1 warn "Bailing out after first failure (--bail)" - break 2 + break fi fi done + (( BAIL_OUT )) && break done echo "" diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 150735e..da93be2 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -64,9 +64,11 @@ via flags or env vars. Options: --platform=P ios | android - --sdk=S flutter | react-native | cordova | capacitor | dotnet | expo | unity | android + --sdk=S flutter | react-native | cordova | capacitor | dotnet | expo | unity | android | ios android = native Android (OneSignal-Android-SDK/examples/demo); skips with exit 0 when --platform=ios. + ios = native iOS (OneSignal-iOS-SDK/examples/demo); + skips with exit 0 when --platform=android. --device=NAME Device/simulator/AVD name (default: iPhone 17 / Samsung Galaxy S26) --appium-port=N Appium server port (default: 4723). Use unique values when running multiple sessions in parallel on the same host. @@ -106,6 +108,10 @@ Env vars (set in .env or export): ANDROID_DIR Native Android SDK repo root (default: ../../OneSignal-Android-SDK) ANDROID_FLAVOR Native Android product flavor (default: gms; also: huawei) ANDROID_BUILD_TYPE Native Android build type (default: debug; also: release) + IOS_DIR Native iOS SDK repo root (default: ../../OneSignal-iOS-SDK) + IOS_NATIVE_PROJECT Xcode project filename under examples/demo for the native + iOS demo (default: App.xcodeproj). The scheme is derived + from the basename (XcodeGen convention). OS_VERSION Platform version (default: 26.2 / 16) IOS_SIMULATOR iOS simulator name (default: iPhone 17) IOS_RUNTIME simctl runtime id (default: iOS-26-2) @@ -160,15 +166,18 @@ prompt_choice() { done } -# --sdk=android implies --platform=android (the native demo only targets -# Android), so resolve PLATFORM first to skip the platform prompt when the -# user only passed --sdk=android. +# Native --sdk= implies --platform= (the native demos only +# target their own OS), so resolve PLATFORM first to skip the platform prompt +# when the user only passed --sdk=android or --sdk=ios. if [[ "${SDK_TYPE:-}" == "android" && -z "${PLATFORM:-}" ]]; then PLATFORM="android" fi +if [[ "${SDK_TYPE:-}" == "ios" && -z "${PLATFORM:-}" ]]; then + PLATFORM="ios" +fi prompt_choice PLATFORM "Select platform:" ios android -prompt_choice SDK_TYPE "Select SDK type:" flutter react-native cordova capacitor dotnet expo unity android +prompt_choice SDK_TYPE "Select SDK type:" flutter react-native cordova capacitor dotnet expo unity android ios case "$PLATFORM" in ios|android) ;; @@ -176,8 +185,8 @@ case "$PLATFORM" in esac case "$SDK_TYPE" in - flutter|react-native|cordova|capacitor|dotnet|expo|unity|android) ;; - *) error "SDK_TYPE must be 'flutter', 'react-native', 'cordova', 'capacitor', 'dotnet', 'expo', 'unity', or 'android', got '$SDK_TYPE'" ;; + flutter|react-native|cordova|capacitor|dotnet|expo|unity|android|ios) ;; + *) error "SDK_TYPE must be 'flutter', 'react-native', 'cordova', 'capacitor', 'dotnet', 'expo', 'unity', 'android', or 'ios', got '$SDK_TYPE'" ;; esac if [[ "$SDK_TYPE" == "android" && "$PLATFORM" != "android" ]]; then @@ -185,6 +194,11 @@ if [[ "$SDK_TYPE" == "android" && "$PLATFORM" != "android" ]]; then exit 0 fi +if [[ "$SDK_TYPE" == "ios" && "$PLATFORM" != "ios" ]]; then + warn "--sdk=ios only runs on --platform=ios; skipping --platform=$PLATFORM" + exit 0 +fi + # ── Real-device validation + signing setup ──────────────────────────────────── # When --device-real is set, we need a physical-device build and codesigning # inputs. Centralised here so the rest of the script stays simulator-shaped @@ -193,7 +207,7 @@ fi if [[ "$IOS_REAL_DEVICE" == true ]]; then [[ "$PLATFORM" == "ios" ]] || error "--device-real only supports --platform=ios" case "$SDK_TYPE" in - cordova|capacitor|react-native|expo) ;; + cordova|capacitor|react-native|expo|ios) ;; android) error "--device-real not applicable to --sdk=android (native Android)" ;; flutter|dotnet) error "--device-real not yet supported for $SDK_TYPE — patch run-local.sh's build_${SDK_TYPE//-/_}_ios to invoke the device build" ;; esac @@ -330,6 +344,14 @@ elif [[ "$SDK_TYPE" == "android" ]]; then esac # Gradle emits per-flavor/type APKs under app/build/outputs/apk///. APP_PATH="${APP_PATH:-$DEMO_DIR/app/build/outputs/apk/${ANDROID_FLAVOR}/${ANDROID_BUILD_TYPE}/app-${ANDROID_FLAVOR}-${ANDROID_BUILD_TYPE}.apk}" +elif [[ "$SDK_TYPE" == "ios" ]]; then + IOS_DIR="${IOS_DIR:-$SDK_ROOT/OneSignal-iOS-SDK}" + [[ -d "$IOS_DIR" ]] || error "Native iOS SDK not found at $IOS_DIR — set IOS_DIR in .env" + DEMO_DIR="$IOS_DIR/examples/demo" + # XcodeGen names the scheme after the project, so we derive both the scheme + # and the .app artifact name from IOS_NATIVE_PROJECT's basename. + IOS_NATIVE_PROJECT="${IOS_NATIVE_PROJECT:-App.xcodeproj}" + APP_PATH="${APP_PATH:-$DEMO_DIR/build/Build/Products/${IOS_BUILD_DIR}/${IOS_NATIVE_PROJECT%.xcodeproj}.app}" fi # ── Platform defaults ──────────────────────────────────────────────────────── @@ -1445,6 +1467,171 @@ build_android_native() { info "App built: $APP_PATH" } +# Hash every source/asset/config file that affects the compiled App.app for a +# native iOS demo build: demo sources (App/, the two extensions, project.yml, +# entitlements, the auto-written Secrets.plist), the regenerated .pbxproj, and +# the SDK framework source pulled in via projectReferences. Folds the SDK +# source into the demo hash so SDK edits cascade-invalidate the cached .app — +# same convention as dotnet_demo_inputs_hash / unity_demo_inputs_hash. Excludes +# test/mock targets (they only build under their own schemes, never "App") and +# xcodebuild-managed dirs. +ios_native_inputs_hash() { + find "$DEMO_DIR" "$IOS_DIR/iOS_SDK/OneSignalSDK" \ + -type f \ + ! -path "*/build/*" \ + ! -path "*/DerivedData/*" \ + ! -path "*/xcuserdata/*" \ + ! -path "*/.git/*" \ + ! -path "*Tests/*" \ + ! -path "*Mocks/*" \ + \( -name "*.swift" -o -name "*.h" -o -name "*.m" -o -name "*.mm" \ + -o -name "*.c" -o -name "*.plist" -o -name "*.entitlements" \ + -o -name "*.yml" -o -name "*.pbxproj" -o -name "*.modulemap" \ + -o -name "*.json" -o -name "*.wav" -o -name "*.png" \ + -o -name "*.xcprivacy" -o -name "*.storyboard" -o -name "*.strings" \) \ + 2>/dev/null \ + | sort \ + | xargs shasum 2>/dev/null \ + | shasum \ + | awk '{print $1}' +} + +# Hash the inputs that affect xcodegen's pbxproj output: project.yml content +# plus the sorted file listing of everything in the demo dir that xcodegen +# could plausibly glob. File listings (not contents) because pbxproj +# references files by path — only adds/removes/renames change it. We scan +# the whole demo dir rather than parsing project.yml's `sources:` entries +# because XcodeGen accepts four equivalent forms (shorthand, inline list, +# list of strings, list of dicts) — any path-extracting parser is a +# future-edit footgun. Over-scanning is harmless: a stray edit (e.g. to a +# README) just triggers one extra ~1s xcodegen run, no false skips. Excludes +# build artifacts and the generated .xcodeproj itself (regenerating it +# would self-bust the hash). +ios_pbxproj_inputs_hash() { + local yml="$DEMO_DIR/project.yml" + [[ -f "$yml" ]] || return 0 + { + shasum "$yml" 2>/dev/null + find "$DEMO_DIR" \ + -type f \ + ! -path "*/build/*" \ + ! -path "*/DerivedData/*" \ + ! -path "*/xcuserdata/*" \ + ! -path "*/.git/*" \ + ! -path "*/$IOS_NATIVE_PROJECT/*" \ + 2>/dev/null \ + | sort + } | shasum | awk '{print $1}' +} + +build_ios_native() { + # Builds the native iOS demo directly so local SDK source changes (under + # OneSignal-iOS-SDK/iOS_SDK/) get exercised end-to-end. The demo's + # App.xcodeproj has a projectReferences entry pointing at the SDK's own + # OneSignal.xcodeproj, so xcodebuild builds the local SDK frameworks + # transitively — mirroring how build_android_native uses the local + # OneSignalSDK module instead of a published artifact. + + # The iOS demo reads credentials from a bundled Secrets.plist (the iOS + # equivalent of .env — see App/Services/SecretsConfig.swift). The file is + # gitignored and lives next to App/Info.plist; project.yml's explicit + # `buildPhase: resources` entry for App/Secrets.plist gets it copied into + # the App bundle. Use `plutil` so API keys with XML-special chars + # (&, <, ", etc.) round-trip safely without manual escaping. + # + # ALWAYS write the file (empty dict when env vars are unset) so xcodebuild's + # Copy Bundle Resources phase doesn't fail on a missing optional resource — + # SecretsConfig falls back to defaultAppId for any keys not present. + # + # Done BEFORE xcodegen (so the file reference is generated against a real + # on-disk file) and BEFORE the hash check (so changing ONESIGNAL_APP_ID / + # ONESIGNAL_API_KEY automatically busts the cache — plutil's output is + # deterministic). + local secrets="$DEMO_DIR/App/Secrets.plist" + if [[ -n "${ONESIGNAL_APP_ID:-}" || -n "${ONESIGNAL_API_KEY:-}" ]]; then + info "Writing Secrets.plist for demo app..." + else + warn "ONESIGNAL_APP_ID / ONESIGNAL_API_KEY not set — writing empty Secrets.plist; demo will fall back to SecretsConfig.defaultAppId" + fi + plutil -create xml1 "$secrets" + [[ -n "${ONESIGNAL_APP_ID:-}" ]] && \ + plutil -insert ONESIGNAL_APP_ID -string "$ONESIGNAL_APP_ID" "$secrets" + [[ -n "${ONESIGNAL_API_KEY:-}" ]] && \ + plutil -insert ONESIGNAL_API_KEY -string "$ONESIGNAL_API_KEY" "$secrets" + + # Only regenerate the .pbxproj when its inputs change. xcodegen 2.45.x is + # NOT deterministic across no-op runs (each `xcodegen generate` produces a + # slightly different .pbxproj even with identical inputs), so unconditional + # regen leaves spurious unstaged changes in the iOS SDK repo on every + # script invocation. Gate on a hash of (project.yml content + sorted file + # listing of the source-globbed dirs) rather than mtime — mtime misses new + # files added to glob-sourced dirs (`App/Foo.swift` without touching + # project.yml leaves pbxproj newer than yml, gate skips, new file is + # missing from the build). File listings rather than contents because + # pbxproj references files by path; only adds/removes/renames affect it. + local proj_path="$DEMO_DIR/$IOS_NATIVE_PROJECT" + local pbxproj="$proj_path/project.pbxproj" + local pbxproj_stamp="$DEMO_DIR/build/.ios-native-pbxproj.stamp" + if [[ -f "$DEMO_DIR/project.yml" ]]; then + if ! command -v xcodegen >/dev/null 2>&1; then + warn "xcodegen not found; using existing $IOS_NATIVE_PROJECT (edits to project.yml will be ignored)" + else + local pbxproj_hash + pbxproj_hash=$(ios_pbxproj_inputs_hash) + if [[ ! -f "$pbxproj" ]] || [[ ! -f "$pbxproj_stamp" ]] \ + || [[ "$(cat "$pbxproj_stamp")" != "$pbxproj_hash" ]]; then + info "Regenerating $IOS_NATIVE_PROJECT from project.yml (xcodegen)..." + (cd "$DEMO_DIR" && xcodegen generate --quiet) + mkdir -p "$(dirname "$pbxproj_stamp")" + echo "$pbxproj_hash" > "$pbxproj_stamp" + else + info "$IOS_NATIVE_PROJECT up to date with project.yml + sources, skipping xcodegen" + fi + fi + fi + + [[ -d "$proj_path" ]] || error "Xcode project not found at $proj_path — set IOS_NATIVE_PROJECT or IOS_DIR" + local scheme="${IOS_NATIVE_PROJECT%.xcodeproj}" + + # Top-level skip: even an incremental xcodebuild costs ~30-60s on a no-op in + # resource copy, framework embed, codesign, and validation. Skip entirely + # when demo + SDK source + Secrets.plist + regenerated pbxproj all match a + # previous build. Mirrors build_expo_ios's stamp-based skip. Stamp is + # scoped by IOS_BUILD_DIR so sim and device builds don't share cache state + # (matches build_dotnet_ios / build_unity_ios; without this, a sim→edit + # SDK→device→sim sequence overwrites the stamp with the post-edit hash + # while the pre-edit sim .app is still on disk, and the skip would serve + # the stale binary). + local build_stamp="$DEMO_DIR/build/.ios-native-build-${IOS_BUILD_DIR}.stamp" + local build_hash + build_hash=$(ios_native_inputs_hash) + if [[ -d "$APP_PATH" ]] && [[ -f "$build_stamp" ]] && [[ "$(cat "$build_stamp")" == "$build_hash" ]]; then + info "Demo + SDK source unchanged, skipping iOS native rebuild" + info "App: $APP_PATH" + return + fi + + info "Building scheme '$scheme' (Release) for ${IOS_SDK}..." + (cd "$DEMO_DIR" && xcodebuild \ + -project "$IOS_NATIVE_PROJECT" \ + -scheme "$scheme" \ + -configuration Release \ + -sdk "$IOS_SDK" \ + ${IOS_DESTINATION:+-destination} ${IOS_DESTINATION:+"$IOS_DESTINATION"} $IOS_XCODE_EXTRA_ARGS \ + -derivedDataPath build \ + -quiet \ + ONLY_ACTIVE_ARCH=YES \ + ENABLE_USER_SCRIPT_SANDBOXING=NO \ + COMPILER_INDEX_STORE_ENABLE=NO \ + SWIFT_INDEX_STORE_ENABLE=NO \ + $IOS_SIGNING_ARGS) + + [[ -d "$APP_PATH" ]] || error ".app not found after build at $APP_PATH" + mkdir -p "$(dirname "$build_stamp")" + echo "$build_hash" > "$build_stamp" + info "App built: $APP_PATH" +} + build_app() { if [[ "$SKIP_BUILD" == true ]]; then if [[ "$PLATFORM" == "ios" && ! -d "$APP_PATH" ]] || [[ "$PLATFORM" == "android" && ! -f "$APP_PATH" ]]; then @@ -1498,6 +1685,8 @@ build_app() { fi elif [[ "$SDK_TYPE" == "android" ]]; then build_android_native + elif [[ "$SDK_TYPE" == "ios" ]]; then + build_ios_native fi } diff --git a/appium/tests/specs/03_iam.spec.ts b/appium/tests/specs/03_iam.spec.ts index e68fc3b..b936d2b 100644 --- a/appium/tests/specs/03_iam.spec.ts +++ b/appium/tests/specs/03_iam.spec.ts @@ -29,11 +29,11 @@ describe('In-App Messaging', () => { { buttonId: 'send_iam_full_screen_button', expectedTitle: 'Full Screen' }, ]; - for (const iam of iamTypes) { - it(`can show ${iam.expectedTitle}`, async () => { + it('can show iam messages', async () => { + for (const iam of iamTypes) { await checkInAppMessage(iam); - }); - } + } + }); it('can pause iam', async () => { await scrollToEl('pause_iam_toggle'); diff --git a/appium/tests/specs/08_outcome.spec.ts b/appium/tests/specs/08_outcome.spec.ts index f9d1cd7..99e5b79 100644 --- a/appium/tests/specs/08_outcome.spec.ts +++ b/appium/tests/specs/08_outcome.spec.ts @@ -18,11 +18,11 @@ describe('Outcomes', () => { }); it('can send a normal outcome', async () => { - const nameInput = await openModal('send_outcome_button', 'outcome_name_input'); - await nameInput.setValue('test_normal'); - - const normalRadio = await byTestId('outcome_type_normal_radio'); + const normalRadio = await openModal('send_outcome_button', 'outcome_type_normal_radio'); await normalRadio.click(); + + const nameInput = await byTestId('outcome_name_input'); + await nameInput.setValue('test_normal'); const sendBtn = await byTestId('outcome_send_button'); await sendBtn.click(); @@ -31,11 +31,11 @@ describe('Outcomes', () => { }); it('can send a unique outcome', async () => { - const nameInput = await openModal('send_outcome_button', 'outcome_name_input'); - await nameInput.setValue('test_unique'); - - const uniqueRadio = await byTestId('outcome_type_unique_radio'); + const uniqueRadio = await openModal('send_outcome_button', 'outcome_type_unique_radio'); await uniqueRadio.click(); + + const nameInput = await byTestId('outcome_name_input'); + await nameInput.setValue('test_unique'); const sendBtn = await byTestId('outcome_send_button'); await sendBtn.click(); @@ -44,11 +44,10 @@ describe('Outcomes', () => { }); it('can send an outcome with value', async () => { - const nameInput = await openModal('send_outcome_button', 'outcome_name_input'); - - const withValueRadio = await byTestId('outcome_type_value_radio'); + const withValueRadio = await openModal('send_outcome_button', 'outcome_type_value_radio'); await withValueRadio.click(); + const nameInput = await byTestId('outcome_name_input'); await nameInput.setValue('test_valued'); const valueInput = await byTestId('outcome_value_input'); diff --git a/demo/build.md b/demo/build.md index 2d1dc8d..3e2166e 100644 --- a/demo/build.md +++ b/demo/build.md @@ -174,7 +174,7 @@ Separate SectionCard titled "User": 1. Status card (always visible, ABOVE buttons): - Two rows separated by a divider: "Status" and "External ID" - - Logged out: Status = "Anonymous", External ID = "–" + - Logged out: Status = "Anonymous", External ID = "—" - Logged in: Status = "Logged In" (success/green), External ID = actual value 2. LOGIN USER button ("SWITCH USER" when logged in) -> dialog with empty "External User Id" field 3. LOGOUT USER button (only when logged in, outlined style) @@ -209,11 +209,11 @@ Separate SectionCard titled "User": - Title: "Send In-App Message" with info icon - Four FULL-WIDTH buttons (not a grid): - 1. TOP BANNER - vertical-align-top icon, trigger: "iam_type" = "top_banner" - 2. BOTTOM BANNER - vertical-align-bottom icon, trigger: "iam_type" = "bottom_banner" - 3. CENTER MODAL - crop-square icon, trigger: "iam_type" = "center_modal" - 4. FULL SCREEN - fullscreen icon, trigger: "iam_type" = "full_screen" -- Styling: primary (red) background, white text, icon on LEFT, full width, left-aligned, UPPERCASE + 1. TOP BANNER - trigger: "iam_type" = "top_banner" + 2. BOTTOM BANNER - trigger: "iam_type" = "bottom_banner" + 3. CENTER MODAL - trigger: "iam_type" = "center_modal" + 4. FULL SCREEN - trigger: "iam_type" = "full_screen" +- Styling: primary (red) background, white text, centered, no icons, full width, UPPERCASE (same as other primary buttons) - On tap: upserts `iam_type` in Triggers list. No snackbar (silent action — see Prompt 7.6) ### Prompt 2.6 - Aliases Section @@ -230,7 +230,7 @@ Separate SectionCard titled "User": - Title: "Emails" with info icon - List with X icon per item (remove action) -- "No Emails Added" when empty +- "No emails added" when empty - ADD EMAIL -> dialog with empty email field - Collapse when >5 items: show first 5, "X more" tappable to expand @@ -238,7 +238,7 @@ Separate SectionCard titled "User": - Title: "SMS" with info icon - Same pattern as Emails but for phone numbers -- "No SMS Added" when empty +- "No SMS added" when empty - ADD SMS -> dialog with empty SMS field ### Prompt 2.9 - Tags Section @@ -261,8 +261,9 @@ Separate SectionCard titled "User": ### Prompt 2.11 - Triggers Section (IN MEMORY ONLY) - Title: "Triggers" with info icon -- Same list/button pattern as Tags (ADD TRIGGER, ADD MULTIPLE TRIGGERS, REMOVE TRIGGERS), plus: +- Same list/button pattern as Tags (ADD TRIGGER, ADD MULTIPLE TRIGGERS, REMOVE TRIGGERS — hidden when the list is empty), plus: - CLEAR ALL TRIGGERS button (only when triggers exist) +- "No triggers added" when empty - Triggers are IN MEMORY ONLY: not persisted, cleared on restart - Sending an IAM also upserts `iam_type` in this list - Transient test data for IAM testing @@ -556,7 +557,7 @@ Single state container at app root. Holds all UI state with public getters. Expo - **SectionCard**: card with title, optional info icon, content slot, onInfoTap callback, optional `sectionKey` for accessibility identifiers (generates `{sectionKey}_section` on the container and `{sectionKey}_info_icon` on the info button) - **ToggleRow**: label, optional description, toggle control, optional `semanticsLabel` for accessibility identifier - **ActionButton**: PrimaryButton (filled) and DestructiveButton (outlined, for secondary/destructive actions), full-width, per styles.md. Both accept optional `semanticsLabel` for accessibility identifier. -- **ListWidgets**: PairItem (key-value + optional delete), SingleItem (value + delete), EmptyState, LoadingState (inline spinner shown in the empty-state slot while a fetch is in flight, per styles.md), CollapsibleList (5 items then expandable; accepts an optional `loading` flag that swaps EmptyState for LoadingState when items is empty), PairList. All list widgets accept a required `sectionKey` for generating accessibility identifiers (e.g. `{sectionKey}_pair_key_{keyText}`, `{sectionKey}_remove_{keyText}`, `{sectionKey}_loading`). +- **ListWidgets**: PairItem (key-value + optional delete), SingleItem (value + delete), EmptyState, LoadingState (inline spinner shown in the empty-state slot while a fetch is in flight, per styles.md), CollapsibleList (5 items then expandable; accepts an optional `loading` flag that swaps EmptyState for LoadingState when items is empty), PairList. All list widgets accept a required `sectionKey` for generating accessibility identifiers (e.g. `{sectionKey}_pair_key_{keyText}`, `{sectionKey}_remove_{keyText}`, `{sectionKey}_loading`). Empty list copy uses `"No added"` with lowercase item names and lowercase `added` (exception: `"No SMS added"` keeps `SMS` uppercase). - **Dialogs**: all full-width with consistent padding. Dialogs accept optional semantics label parameters for key inputs and confirm buttons (e.g. `keySemanticsLabel`, `valueSemanticsLabel`, `confirmSemanticsLabel`). - SingleInputDialog, PairInputDialog (same row), MultiPairInputDialog (dynamic rows, dividers, X to delete, batch submit), MultiSelectRemoveDialog (checkboxes, batch remove) - LoginDialog, OutcomeDialog, TrackEventDialog, CustomNotificationDialog, TooltipDialog @@ -575,6 +576,7 @@ Shared by Aliases, Tags, and Triggers ADD MULTIPLE buttons. Shared by Tags and Triggers REMOVE buttons. +- The section button that opens this dialog (`remove_tags_button`, `remove_triggers_button`) is **hidden entirely** when the list is empty. Do not show it disabled. - Checkbox per item, label shows key only - "Remove (N)" button shows selected count, disabled when none - Returns selected keys list @@ -587,21 +589,45 @@ Implement theme constants/tokens mapping style reference to the platform's themi ### Prompt 7.6 - Feedback Messages (SnackBar/Toast) -Feedback messages are shown directly from the UI layer (not centralized in the state management layer). Use a `BuildContext` extension or helper that calls the platform's transient message API (SnackBar/Toast). The extension should hide the current message before showing a new one. Show snackbars from UI widget callbacks after awaiting the action, using a context-mounted check before displaying. +Feedback messages are shown directly from the UI layer (not centralized in the state management layer). Wrap the platform's transient message API (SnackBar/Toast) in a single UI-layer helper -- a `BuildContext` extension, a hook returned by a provider, an injected controller, a static helper, etc. -- and call it from widget/section callbacks after the SDK action runs. See each SDK's `examples/build.md` for the concrete helper name, file location, and wiring. + +**Replace on show:** when a new snackbar/toast is triggered while one is already visible, dismiss the current message immediately and show the new one (do not queue). Reset the 3s timer from when the new message appears. The helper must perform the dismiss-then-show internally so callers can fire `showSnackbar(...)` repeatedly without coordinating timers themselves. + +**Duration:** show every snackbar/toast for **3000ms (3 seconds)**. Use an explicit named constant (e.g. `TOAST_DURATION_MS = 3000`) rather than platform defaults, so all demos behave the same in manual use and Appium runs. Only the following actions show snackbar feedback from the UI: -- Login/Logout: "Logged in as {userId}" / "User logged out" - Outcomes: "Outcome sent: {name}" / "Unique outcome sent: {name}" / "Outcome sent: {name} = {value}" - Custom Events: "Event tracked: {name}" - Location check: "Location shared: {bool}" -All other actions (add/remove items, notifications, IAM, live activities, etc.) use the platform's standard logging primitive only -- no snackbar. The state management layer should NOT hold snackbar state or expose snackbar messages. +All other actions (login/logout, add/remove items, notifications, IAM, live activities, etc.) use the platform's standard logging primitive only -- no snackbar. The state management layer (ViewModel / Context / Store / etc.) MUST NOT hold snackbar state, expose snackbar messages (e.g. via `@Published`, `Flow`, `Channel`, observable events), or call the snackbar helper directly. Logging: - Use the platform's built-in logging primitive directly (`console.log`/`console.error` for JS/TS, `debugPrint` for Dart, `System.Diagnostics.Debug.WriteLine` for C#, `print`/`NSLog` for Swift, `Log.d`/`Log.e` for Kotlin/Java). +### Prompt 7.7 - Dialog Placement + +Each section owns the dialogs for its actions (login, add/remove, outcomes, track event, custom push). The main screen/page owns tooltip dialogs only. + +**Main screen/page pattern:** + +- Layout + the tooltip dialog only. Hold a single piece of tooltip state (active key or open boolean) on the main screen. +- Pass `onInfoTap` / `onInfoClick` callbacks keyed by section so the main screen can show the matching tooltip when a section's info icon is tapped. +- Do not centralize action dialog visibility on the main screen. +- Do not store action dialog visibility, dialog input drafts, or "is dialog open" flags on the ViewModel / state container. + +**Section pattern:** + +1. Section declares local UI state for each of its action dialogs (`*Open` booleans, `@State` properties, `remember { mutableStateOf(false) }`, code-behind handlers, or an imperative `showDialog(...)` call -- whichever is idiomatic). +2. Button handler triggers the matching dialog. +3. Dialog confirm handler calls the SDK action via the ViewModel / repository, closes the dialog, and emits a snackbar (per Prompt 7.6) when the action is in the allowed-snackbar list. + +**Shared dialog primitives** (single-input dialog, pair input, multi-pair, multi-select remove, login, outcome, track event, custom notification, tooltip) live in a platform-appropriate components folder; sections import and compose them locally rather than redeclaring per-section dialog markup. + +See each SDK's `examples/build.md` for the platform-specific helper APIs, the exact shared-dialog folder, and any platform-idiomatic wiring (UI-tree injection mechanism, dialog presentation API, code-behind vs. declarative state, etc.). + --- ## Configuration