diff --git a/OneSignalSDK.DotNet.Android/AndroidNotificationsManager.cs b/OneSignalSDK.DotNet.Android/AndroidNotificationsManager.cs index e7f9611..6c74b3c 100644 --- a/OneSignalSDK.DotNet.Android/AndroidNotificationsManager.cs +++ b/OneSignalSDK.DotNet.Android/AndroidNotificationsManager.cs @@ -1,4 +1,4 @@ -using OneSignalSDK.DotNet.Android.Utilities; +using OneSignalSDK.DotNet.Android.Utilities; using OneSignalSDK.DotNet.Core; using OneSignalSDK.DotNet.Core.Internal.Utilities; using OneSignalSDK.DotNet.Core.Notifications; @@ -40,6 +40,11 @@ public async Task RequestPermissionAsync(bool fallbackToSettings) return await consumer; } + public void ClearAllNotifications() + { + OneSignalNative.Notifications.ClearAllNotifications(); + } + private class InternalNotificationsEventsHandler : Java.Lang.Object, Com.OneSignal.Android.Notifications.IPermissionObserver, diff --git a/OneSignalSDK.DotNet.Core/Notifications/INotificationsManager.cs b/OneSignalSDK.DotNet.Core/Notifications/INotificationsManager.cs index 1b3057b..907b17e 100644 --- a/OneSignalSDK.DotNet.Core/Notifications/INotificationsManager.cs +++ b/OneSignalSDK.DotNet.Core/Notifications/INotificationsManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Security; using System.Threading.Tasks; using System.Xml.Linq; @@ -65,5 +65,10 @@ public interface INotificationsManager /// the result is false if the user is opted out of notifications permission(user rejected). /// Task RequestPermissionAsync(bool fallbackToSettings); + + /// + /// Removes all OneSignal notifications from the Notification Shade. + /// + void ClearAllNotifications(); } } diff --git a/OneSignalSDK.DotNet.iOS/iOSNotificationsManager.cs b/OneSignalSDK.DotNet.iOS/iOSNotificationsManager.cs index c431552..436593a 100644 --- a/OneSignalSDK.DotNet.iOS/iOSNotificationsManager.cs +++ b/OneSignalSDK.DotNet.iOS/iOSNotificationsManager.cs @@ -1,4 +1,4 @@ -using System.Security.Permissions; +using System.Security.Permissions; using Com.OneSignal.iOS; using Foundation; using OneSignalSDK.DotNet.Core; @@ -46,6 +46,11 @@ public async Task RequestPermissionAsync(bool fallbackToSettings) return await proxy; } + public void ClearAllNotifications() + { + OneSignalNative.Notifications.ClearAll(); + } + private sealed class InternalNotificationsPermissionObserver : Com.OneSignal.iOS.OSNotificationPermissionObserver { diff --git a/examples/build.md b/examples/build.md index 95bfbe6..b219cfe 100644 --- a/examples/build.md +++ b/examples/build.md @@ -1,949 +1,263 @@ # OneSignal .NET MAUI Sample App - Build Guide -This document contains all the prompts and requirements needed to build the OneSignal .NET MAUI Sample App from scratch. Give these prompts to an AI assistant or follow them manually to recreate the app. +This document extends the shared build guide with .NET MAUI-specific details. ---- - -## Phase 0: Reference Screenshots (REQUIRED) - -### Prompt 0.1 - Capture Reference UI - -Before building anything, an Android emulator MUST be running with the -reference OneSignal demo app installed. These screenshots are the source -of truth for the UI you are building. Do NOT proceed to Phase 1 without them. - -Check for connected emulators: - adb devices - -If no device is listed, stop and ask the user to start one. - -Identify which emulator has com.onesignal.sdktest installed by checking each listed device, e.g.: - adb -s emulator-5554 shell pm list packages 2>/dev/null | grep -i onesignal - adb -s emulator-5556 shell pm list packages 2>/dev/null | grep -i onesignal - -Use that emulator's serial (e.g. emulator-5556) for all subsequent adb commands via the -s flag. - -Launch the reference app: - adb -s shell am start -n com.onesignal.sdktest/.ui.main.MainActivity - -Dismiss any in-app messages that appear on launch. Tap the X or -click-through button on each IAM until the main UI is fully visible -with no overlays. - -Create an output directory: - mkdir -p /tmp/onesignal_reference - -Capture screenshots by scrolling through the full UI: -1. Take a screenshot from the top of the screen: - adb shell screencap -p /sdcard/ref_01.png && adb pull /sdcard/ref_01.png /tmp/onesignal_reference/ref_01.png -2. Scroll down by roughly one viewport height: - adb shell input swipe 500 1500 500 500 -3. Take the next screenshot (ref_02.png, ref_03.png, etc.) -4. Repeat until you've reached the bottom of the scrollable content - -You MUST read each captured screenshot image so you can see the actual UI. -These images define the visual target for every section you build later. -Pay close attention to: - - Section header style and casing - - Card vs non-card content grouping - - Button placement (inside vs outside cards) - - List item layout (stacked vs inline key-value) - - Icon choices (delete, close, info, etc.) - - Typography, spacing, and colors - -You can also interact with the reference app to observe specific flows: - -Dump the UI hierarchy to find elements by resource-id, text, or content-desc: - adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml /tmp/onesignal_reference/ui.xml +**Read the shared guide first:** +https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/demo/build.md -Parse the XML to find an element's bounds, then tap it: - adb shell input tap - -Type into a focused text field: - adb shell input text "test" - -Example flow to observe "Add Tag" behavior: - 1. Dump UI -> find the ADD button bounds -> tap it - 2. Dump UI -> find the Key and Value fields -> tap and type into them - 3. Tap the confirm button -> screenshot the result - 4. Compare the tag list state before and after - -Also capture screenshots of key dialogs to match their layout: - - Add Alias (single pair input) - - Add Multiple Aliases/Tags (dynamic rows with add/remove) - - Remove Selected Tags (checkbox multi-select) - - Login User - - Send Outcome (radio options) - - Track Event (with JSON properties field) - - Custom Notification (title + body) -These dialog screenshots are important for matching field layout, -button placement, spacing, and validation behavior. - -Refer back to these screenshots throughout all remaining phases whenever -you need to decide on layout, spacing, section order, dialog flows, or -overall look and feel. +Replace `{{PLATFORM}}` with `DotNet` everywhere in that guide. Everything below either overrides or supplements sections from the shared guide. --- -## Phase 1: Initial Setup +## Project Foundation -### Prompt 1.1 - Project Foundation +- Framework: .NET MAUI with C# nullable reference types enabled +- Architecture: MVVM via CommunityToolkit.Mvvm (INotifyPropertyChanged / ObservableObject) +- UI: XAML pages and controls (not code-only) +- Target frameworks: `net10.0-android;net10.0-ios` -Create a new .NET MAUI project at examples/demo/ (relative to the SDK repo root). - -Build the app with: -- Clean architecture: repository pattern with INotifyPropertyChanged-based state management (MVVM) -- .NET 10+ with C# nullable reference types enabled -- Material-style theming with OneSignal brand colors -- App name: "OneSignal Demo" -- Top app bar: title "DotNet" with the OneSignal logo image -- Support for both Android and iOS -- Android package name: com.onesignal.example -- iOS bundle identifier: com.onesignal.example -- All dialogs should have EMPTY input fields (for Appium testing - test framework enters values) -- Separate XAML page/view files per section to keep files focused and readable - -Download the OneSignal logo PNG from: +Download the OneSignal logo as PNG (not SVG, MAUI does not natively render SVG): https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/assets/onesignal_logo.png -Save it to Resources/Images/onesignal_logo.png and use it in the NavigationPage title. - -Download the padded app icon PNG from: - https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/assets/onesignal_logo_icon_padded.png -Save it to Resources/AppIcon/appicon.png and configure it as the app icon in the .csproj with -a white background color (the source PNG is transparent; Color fills it so iOS icons render correctly). -ForegroundScale keeps the icon within Android's adaptive icon safe zone so it doesn't appear zoomed in: - +Save to `Resources/Images/onesignal_logo.png` and use it in the NavigationPage title. -Reuse the same icon as the splash screen: - +App icon setup (the source PNG is transparent; Color fills it so iOS icons render correctly): +```xml + + +``` -Reference the OneSignal .NET SDK from the parent repo using a project reference: - +SDK reference via project reference: +```xml + +``` -### Prompt 1.2 - Dependencies (.csproj) +--- -Add these packages to the demo .csproj: +## Dependencies (.csproj) +```xml - - +``` -Target frameworks: - net10.0-android;net10.0-ios - -Android-specific setup: - - google-services.json included as GoogleServicesJson build action - - Run setup-devapp.sh (at examples/) to copy google-services.json from above the repo root - - AndroidManifest.xml must include the following permissions: - - - -iOS-specific setup: - - Entitlements.plist with push notification entitlement (aps-environment = development) - - Info.plist with NSLocationWhenInUseUsageDescription for location prompts - - Info.plist must include XSAppIconAssets so the build system passes --app-icon to actool: - XSAppIconAssets - Assets.xcassets/appicon.appiconset - - The NotificationServiceExtension .csproj must include TrimmerRootAssembly for System.Net.Mail - (the OneSignal native SDK depends on it; without it the NSE crashes on launch in aot-only mode): - - -### Prompt 1.3 - OneSignal Repository - -Create a OneSignalRepository class that centralizes all OneSignal SDK calls. -This is a plain C# class (not a ViewModel) injected into the ViewModel. - -Use the static OneSignal class from OneSignalSDK.DotNet: - using OneSignalSDK.DotNet; - -User operations: -- LoginUser(string externalUserId) -> void [OneSignal.Login(externalUserId)] -- LogoutUser() -> void [OneSignal.Logout()] - -Alias operations: -- AddAlias(string label, string id) -> void [OneSignal.User.AddAlias(label, id)] -- AddAliases(IDictionary aliases) -> void [OneSignal.User.AddAliases(aliases)] - -Email operations: -- AddEmail(string email) -> void [OneSignal.User.AddEmail(email)] -- RemoveEmail(string email) -> void [OneSignal.User.RemoveEmail(email)] - -SMS operations: -- AddSms(string smsNumber) -> void [OneSignal.User.AddSms(smsNumber)] -- RemoveSms(string smsNumber) -> void [OneSignal.User.RemoveSms(smsNumber)] - -Tag operations: -- AddTag(string key, string value) -> void [OneSignal.User.AddTag(key, value)] -- AddTags(IDictionary tags) -> void [OneSignal.User.AddTags(tags)] -- RemoveTag(string key) -> void [OneSignal.User.RemoveTag(key)] -- RemoveTags(IEnumerable keys) -> void [OneSignal.User.RemoveTags(keys.ToArray())] -- GetTags() -> IDictionary [OneSignal.User.GetTags()] - -Trigger operations (via OneSignal.InAppMessages): -- AddTrigger(string key, string value) -> void [OneSignal.InAppMessages.AddTrigger(key, value)] -- AddTriggers(IDictionary triggers) -> void [OneSignal.InAppMessages.AddTriggers(triggers)] -- RemoveTrigger(string key) -> void [OneSignal.InAppMessages.RemoveTrigger(key)] -- RemoveTriggers(IEnumerable keys) -> void [OneSignal.InAppMessages.RemoveTriggers(keys.ToArray())] -- ClearTriggers() -> void [OneSignal.InAppMessages.ClearTriggers()] - -Outcome operations (via OneSignal.Session): -- SendOutcome(string name) -> void [OneSignal.Session.AddOutcome(name)] -- SendUniqueOutcome(string name) -> void [OneSignal.Session.AddUniqueOutcome(name)] -- SendOutcomeWithValue(string name, float value) -> void [OneSignal.Session.AddOutcomeWithValue(name, value)] - -Track Event: -- TrackEvent(string name, IDictionary? properties) -> void [OneSignal.User.TrackEvent(name, properties)] - -Push subscription: -- GetPushSubscriptionId() -> string? [OneSignal.User.PushSubscription.Id] -- IsPushOptedIn() -> bool [OneSignal.User.PushSubscription.OptedIn] -- OptInPush() -> void [OneSignal.User.PushSubscription.OptIn()] -- OptOutPush() -> void [OneSignal.User.PushSubscription.OptOut()] - -Notifications: -- HasPermission() -> bool [OneSignal.Notifications.Permission] -- RequestPermissionAsync(bool fallbackToSettings) -> Task [OneSignal.Notifications.RequestPermissionAsync(fallbackToSettings)] - -In-App Messages: -- SetInAppMessagesPaused(bool paused) -> void [OneSignal.InAppMessages.Paused = paused] -- IsInAppMessagesPaused() -> bool [OneSignal.InAppMessages.Paused] - -Location: -- SetLocationShared(bool shared) -> void [OneSignal.Location.IsShared = shared] -- IsLocationShared() -> bool [OneSignal.Location.IsShared] -- RequestLocationPermission() -> void [OneSignal.Location.RequestPermission()] - -Privacy consent: -- SetConsentRequired(bool required) -> void [OneSignal.ConsentRequired = required] -- SetConsentGiven(bool granted) -> void [OneSignal.ConsentGiven = granted] - -User IDs: -- GetExternalId() -> string? [OneSignal.User.ExternalId] -- GetOnesignalId() -> string? [OneSignal.User.OneSignalId] - -Notification sending (via REST API, delegated to OneSignalApiService): -- SendNotificationAsync(NotificationType type) -> Task -- SendCustomNotificationAsync(string title, string body) -> Task -- FetchUserAsync(string onesignalId) -> Task +--- -### Prompt 1.4 - OneSignalApiService (REST API Client) +## Platform Setup -Create OneSignalApiService class for REST API calls using HttpClient: +### Android +- google-services.json included as GoogleServicesJson build action +- Run `examples/setup-devapp.sh` to copy google-services.json from above the repo root +- AndroidManifest.xml must include: +```xml + + +``` -Properties: -- _appId: string (set during initialization) +### iOS +- Entitlements.plist with push notification entitlement (`aps-environment = development`) +- Info.plist with `NSLocationWhenInUseUsageDescription` for location prompts +- Info.plist must include XSAppIconAssets so the build system passes --app-icon to actool: +```xml +XSAppIconAssets +Assets.xcassets/appicon.appiconset +``` +- The NotificationServiceExtension .csproj must include TrimmerRootAssembly for System.Net.Mail + (the OneSignal native SDK depends on it; without it the NSE crashes on launch in aot-only mode): +```xml + +``` -Methods: -- SetAppId(string appId) -- GetAppId() -> string -- SendNotificationAsync(NotificationType type, string subscriptionId) -> Task -- SendCustomNotificationAsync(string title, string body, string subscriptionId) -> Task -- FetchUserAsync(string onesignalId) -> Task +--- -SendNotificationAsync endpoint: -- POST https://onesignal.com/api/v1/notifications -- Accept header: "application/vnd.onesignal.v1+json" -- Content-Type: "application/json" -- Uses include_subscription_ids (not include_player_ids) -- Includes big_picture for Android image notifications -- Includes ios_attachments for iOS image notifications (needed for the NSE to download and attach images) +## OneSignal Repository (SDK API Mapping) + +Use the static `OneSignal` class from `OneSignalSDK.DotNet`: + +| Operation | SDK Call | +|---|---| +| LoginUser(externalUserId) | `OneSignal.Login(externalUserId)` | +| LogoutUser() | `OneSignal.Logout()` | +| AddAlias(label, id) | `OneSignal.User.AddAlias(label, id)` | +| AddAliases(aliases) | `OneSignal.User.AddAliases(aliases)` | +| AddEmail(email) | `OneSignal.User.AddEmail(email)` | +| RemoveEmail(email) | `OneSignal.User.RemoveEmail(email)` | +| AddSms(number) | `OneSignal.User.AddSms(number)` | +| RemoveSms(number) | `OneSignal.User.RemoveSms(number)` | +| AddTag(key, value) | `OneSignal.User.AddTag(key, value)` | +| AddTags(tags) | `OneSignal.User.AddTags(tags)` | +| RemoveTag(key) | `OneSignal.User.RemoveTag(key)` | +| RemoveTags(keys) | `OneSignal.User.RemoveTags(keys.ToArray())` | +| GetTags() | `OneSignal.User.GetTags()` | +| AddTrigger(key, value) | `OneSignal.InAppMessages.AddTrigger(key, value)` | +| AddTriggers(triggers) | `OneSignal.InAppMessages.AddTriggers(triggers)` | +| RemoveTrigger(key) | `OneSignal.InAppMessages.RemoveTrigger(key)` | +| RemoveTriggers(keys) | `OneSignal.InAppMessages.RemoveTriggers(keys.ToArray())` | +| ClearTriggers() | `OneSignal.InAppMessages.ClearTriggers()` | +| SendOutcome(name) | `OneSignal.Session.AddOutcome(name)` | +| SendUniqueOutcome(name) | `OneSignal.Session.AddUniqueOutcome(name)` | +| SendOutcomeWithValue(name, value) | `OneSignal.Session.AddOutcomeWithValue(name, value)` | +| TrackEvent(name, properties) | `OneSignal.User.TrackEvent(name, properties)` | +| GetPushSubscriptionId() | `OneSignal.User.PushSubscription.Id` | +| IsPushOptedIn() | `OneSignal.User.PushSubscription.OptedIn` | +| OptInPush() | `OneSignal.User.PushSubscription.OptIn()` | +| OptOutPush() | `OneSignal.User.PushSubscription.OptOut()` | +| ClearAllNotifications() | `OneSignal.Notifications.ClearAll()` | +| HasPermission() | `OneSignal.Notifications.Permission` | +| RequestPermissionAsync(fallback) | `OneSignal.Notifications.RequestPermissionAsync(fallback)` | +| SetInAppMessagesPaused(paused) | `OneSignal.InAppMessages.Paused = paused` | +| IsInAppMessagesPaused() | `OneSignal.InAppMessages.Paused` | +| SetLocationShared(shared) | `OneSignal.Location.IsShared = shared` | +| IsLocationShared() | `OneSignal.Location.IsShared` | +| RequestLocationPermission() | `OneSignal.Location.RequestPermission()` | +| SetConsentRequired(required) | `OneSignal.ConsentRequired = required` | +| SetConsentGiven(granted) | `OneSignal.ConsentGiven = granted` | +| GetExternalId() | `OneSignal.User.ExternalId` | +| GetOnesignalId() | `OneSignal.User.OneSignalId` | + +REST API client uses `HttpClient`. JSON parsing via `System.Text.Json`. -FetchUserAsync endpoint: -- GET https://api.onesignal.com/apps/{app_id}/users/by/onesignal_id/{onesignal_id} -- NO Authorization header needed (public endpoint) -- Returns UserData with aliases, tags, emails, smsNumbers, externalId +--- -### Prompt 1.5 - SDK Observers +## SDK Initialization & Observers -In MauiProgram.cs (or App.xaml.cs), set up OneSignal initialization before the app runs: +In MauiProgram.cs (or App.xaml.cs), before the app runs: +```csharp OneSignal.Debug.LogLevel = LogLevel.Verbose; OneSignal.ConsentRequired = cachedConsentRequired; OneSignal.ConsentGiven = cachedPrivacyConsent; OneSignal.Initialize(appId); +``` -Then register event handlers: -- OneSignal.InAppMessages.WillDisplay += OnIamWillDisplay -- OneSignal.InAppMessages.DidDisplay += OnIamDidDisplay -- OneSignal.InAppMessages.WillDismiss += OnIamWillDismiss -- OneSignal.InAppMessages.DidDismiss += OnIamDidDismiss -- OneSignal.InAppMessages.Clicked += OnIamClicked -- OneSignal.Notifications.Clicked += OnNotificationClicked -- OneSignal.Notifications.WillDisplay += OnNotificationWillDisplay +Event handlers (C# events, not callbacks): +```csharp +OneSignal.InAppMessages.WillDisplay += OnIamWillDisplay; +OneSignal.InAppMessages.DidDisplay += OnIamDidDisplay; +OneSignal.InAppMessages.WillDismiss += OnIamWillDismiss; +OneSignal.InAppMessages.DidDismiss += OnIamDidDismiss; +OneSignal.InAppMessages.Clicked += OnIamClicked; +OneSignal.Notifications.Clicked += OnNotificationClicked; +OneSignal.Notifications.WillDisplay += OnNotificationWillDisplay; +``` -After initialization, restore cached SDK states from Preferences: -- OneSignal.InAppMessages.Paused = cachedPausedStatus -- OneSignal.Location.IsShared = cachedLocationShared +After initialization, restore cached state: +```csharp +OneSignal.InAppMessages.Paused = cachedPausedStatus; +OneSignal.Location.IsShared = cachedLocationShared; +``` -In AppViewModel, register observers: -- OneSignal.User.PushSubscription.Changed += OnPushSubscriptionChanged (react to push subscription changes) -- OneSignal.Notifications.PermissionChanged += OnPermissionChanged (react to permission changes) -- OneSignal.User.Changed += OnUserChanged (call FetchUserDataFromApiAsync() when user changes) +ViewModel observers: +```csharp +OneSignal.User.PushSubscription.Changed += OnPushSubscriptionChanged; +OneSignal.Notifications.PermissionChanged += OnPermissionChanged; +OneSignal.User.Changed += OnUserChanged; +``` --- -## Phase 2: UI Sections - -### Section Order (top to bottom) - -1. **App Section** (App ID, Guidance Banner, Consent Toggle) -2. **User Section** (Status, External ID, Login/Logout) -3. **Push Section** (Push ID, Enabled Toggle, Auto-prompts permission on load) -4. **Send Push Notification Section** (Simple, With Image, Custom buttons) -5. **In-App Messaging Section** (Pause toggle) -6. **Send In-App Message Section** (Top Banner, Bottom Banner, Center Modal, Full Screen - with icons) -7. **Aliases Section** (Add/Add Multiple, read-only list) -8. **Emails Section** (Collapsible list >5 items) -9. **SMS Section** (Collapsible list >5 items) -10. **Tags Section** (Add/Add Multiple/Remove Selected) -11. **Outcome Events Section** (Send Outcome dialog with type selection) -12. **Triggers Section** (Add/Add Multiple/Remove Selected/Clear All - IN MEMORY ONLY) -13. **Track Event Section** (Track Event with JSON validation) -14. **Location Section** (Location Shared toggle, Prompt Location button) -15. **Next Page Button** - -### Prompt 2.1a - App Section - -App Section layout: - -1. App ID display (readonly Label showing the OneSignal App ID) - -2. Sticky guidance banner below App ID: - - Text: "Add your own App ID, then rebuild to fully test all functionality." - - Link text: "Get your keys at onesignal.com" (tappable, opens browser via Launcher.OpenAsync) - - Warning banner styling per styles.md - -3. Consent card with up to two toggles: - a. "Consent Required" toggle (always visible): - - Label: "Consent Required" - - Description: "Require consent before SDK processes data" - - Sets OneSignal.ConsentRequired = value - b. "Privacy Consent" toggle (only visible when Consent Required is ON): - - Label: "Privacy Consent" - - Description: "Consent given for data collection" - - Sets OneSignal.ConsentGiven = value - - Separated from the above toggle by a horizontal divider - - NOT a blocking overlay - user can interact with app regardless of state - -### Prompt 2.1b - User Section - -User Section layout (separate SectionCard titled "User", placed after App Section): - -1. User status card (always visible, ABOVE the login/logout buttons): - - Card with two rows separated by a divider - - Row 1: "Status" label on the left, value on the right - - Row 2: "External ID" label on the left, value on the right - - When logged out: - - Status shows "Anonymous" - - External ID shows "–" (dash) - - When logged in: - - Status shows "Logged In" with green styling - - External ID shows the actual external user ID - -2. LOGIN USER button: - - Shows "LOGIN USER" when no user is logged in - - Shows "SWITCH USER" when a user is logged in - - Opens "Login User" dialog with empty "External User Id" field - -3. LOGOUT USER button (only visible when a user is logged in) - -### Prompt 2.2 - Push Section - -Push Section: -- Section title: "Push" with info icon for tooltip -- Push Subscription ID display (readonly Label) -- Enabled toggle switch (controls OptIn/OptOut) - - Disabled when notification permission is NOT granted -- Notification permission is automatically requested when the main page loads -- PROMPT PUSH button: - - Only visible when notification permission is NOT granted (fallback if user denied) - - Calls OneSignal.Notifications.RequestPermissionAsync(true) when clicked - - Hidden once permission is granted - -### Prompt 2.3 - Send Push Notification Section - -Send Push Notification Section (placed right after Push Section): -- Section title: "Send Push Notification" with info icon for tooltip -- Three buttons: - 1. SIMPLE - title: "Simple Notification", body: "This is a simple push notification" - 2. WITH IMAGE - title: "Image Notification", body: "This notification includes an image" - big_picture (Android): https://media.onesignal.com/automated_push_templates/ratings_template.png - ios_attachments (iOS): {"image": "https://media.onesignal.com/automated_push_templates/ratings_template.png"} - 3. CUSTOM - opens one CommunityToolkit.Maui popup with Title and Body fields - -Tooltip should explain each button type. - -### Prompt 2.4 - In-App Messaging Section - -In-App Messaging Section (placed right after Send Push): -- Section title: "In-App Messaging" with info icon for tooltip -- Pause In-App Messages toggle switch: - - Label: "Pause In-App Messages" - - Description: "Toggle in-app message display" - - Sets OneSignal.InAppMessages.Paused = value - -### Prompt 2.5 - Send In-App Message Section - -Send In-App Message Section (placed right after In-App Messaging): -- Section title: "Send In-App Message" with info icon for tooltip -- Four FULL-WIDTH buttons (not a grid): - 1. TOP BANNER - icon: Material.VerticalAlignTop, trigger: "iam_type" = "top_banner" - 2. BOTTOM BANNER - icon: Material.VerticalAlignBottom, trigger: "iam_type" = "bottom_banner" - 3. CENTER MODAL - icon: Material.CropSquare, trigger: "iam_type" = "center_modal" - 4. FULL SCREEN - icon: Material.Fullscreen, trigger: "iam_type" = "full_screen" -- Button styling: primary (red) background, white text, type-specific icon on - LEFT side only, full width, left-aligned content, UPPERCASE text -- On tap: calls OneSignal.InAppMessages.AddTrigger("iam_type", value) and shows a Toast/DisplayAlert - - Also upserts "iam_type" in the Triggers list immediately so UI reflects the sent IAM type - -Tooltip should explain each IAM type. - -### Prompt 2.6 - Aliases Section - -Aliases Section (placed after Send In-App Message): -- Section title: "Aliases" with info icon for tooltip -- List showing key-value pairs (read-only, no delete icons) -- Each item shows label (key) bold on top, ID (value) below in lighter style (stacked, same as Tags/Triggers) -- Filter out "external_id" and "onesignal_id" from display (these are special) -- "No Aliases Added" text when empty -- ADD button -> DialogInputHelper.ShowPairInput with Label and ID fields side by side in one row - [calls OneSignal.User.AddAlias(label, id)] -- ADD MULTIPLE button -> MultiPairInputDialog/Popup (dynamic rows, add/remove) - [calls OneSignal.User.AddAliases(aliases)] -- No remove/delete functionality (aliases are add-only from the UI) +## UI Notes -### Prompt 2.7 - Emails Section - -Emails Section: -- Section title: "Emails" with info icon for tooltip -- List showing email addresses -- Each item shows email with an X button (remove action) - [calls OneSignal.User.RemoveEmail(email)] -- "No Emails Added" text when empty -- ADD EMAIL button -> CommunityToolkit.Maui popup with one "Email address" field - [calls OneSignal.User.AddEmail(email)] -- Collapse behavior when >5 items: - - Show first 5 items - - Show "X more" Label (tappable) - - Expand to show all when tapped - -### Prompt 2.8 - SMS Section - -SMS Section: -- Section title: "SMS" with info icon for tooltip -- List showing phone numbers -- Each item shows phone number with an X button (remove action) - [calls OneSignal.User.RemoveSms(number)] -- "No SMS Added" text when empty -- ADD SMS button -> CommunityToolkit.Maui popup with one "Phone number" field (Telephone keyboard) - [calls OneSignal.User.AddSms(number)] -- Collapse behavior when >5 items (same as Emails) - -### Prompt 2.9 - Tags Section - -Tags Section: -- Section title: "Tags" with info icon for tooltip -- List showing key-value pairs -- Each item shows key above value (stacked layout) with an X button on the right (remove action) - [calls OneSignal.User.RemoveTag(key)] -- "No Tags Added" text when empty -- ADD button -> DialogInputHelper.ShowPairInput with Key and Value fields side by side in one row - [calls OneSignal.User.AddTag(key, value)] -- ADD MULTIPLE button -> CommunityToolkit.Maui Popup overlay with dynamic key-value rows - [calls OneSignal.User.AddTags(tags)] -- REMOVE SELECTED button: - - Only visible when at least one tag exists - - Opens CommunityToolkit.Maui Popup overlay with checkboxes - [calls OneSignal.User.RemoveTags(selectedKeys)] - -### Prompt 2.10 - Outcome Events Section - -Outcome Events Section: -- Section title: "Outcome Events" with info icon for tooltip -- SEND OUTCOME button -> CommunityToolkit.Maui popup with: - - Three inline RadioButton options (grouped, no picker): - 1. Normal Outcome (default selected) [calls OneSignal.Session.AddOutcome(name)] - 2. Unique Outcome [calls OneSignal.Session.AddUniqueOutcome(name)] - 3. Outcome with Value [calls OneSignal.Session.AddOutcomeWithValue(name, value)] - - Outcome name Entry field (AutomationId: outcome_name_input) - - Value Entry field (float, numeric keyboard, AutomationId: outcome_value_input) — only visible when "Outcome with Value" is selected - -### Prompt 2.11 - Triggers Section (IN MEMORY ONLY) - -Triggers Section: -- Section title: "Triggers" with info icon for tooltip -- List showing key-value pairs -- Each item shows key above value (stacked layout) with an X button on the right (remove action) - [calls OneSignal.InAppMessages.RemoveTrigger(key)] -- "No Triggers Added" text when empty -- ADD button -> DialogInputHelper.ShowPairInput with Key and Value fields side by side in one row - [calls OneSignal.InAppMessages.AddTrigger(key, value)] -- ADD MULTIPLE button -> CommunityToolkit.Maui Popup overlay with dynamic key-value rows - [calls OneSignal.InAppMessages.AddTriggers(triggers)] -- Two action buttons (only visible when triggers exist): - - REMOVE SELECTED -> CommunityToolkit.Maui Popup overlay with checkboxes - [calls OneSignal.InAppMessages.RemoveTriggers(selectedKeys)] - - CLEAR ALL -> Removes all triggers at once - [calls OneSignal.InAppMessages.ClearTriggers()] - -IMPORTANT: Triggers are stored IN MEMORY ONLY during the app session. -- TriggersList is an ObservableCollection> in AppViewModel -- Sending an IAM button also updates the same list by setting "iam_type" -- Triggers are NOT persisted to Preferences -- Triggers are cleared when the app is killed/restarted -- This is intentional - triggers are transient test data for IAM testing - -### Prompt 2.12 - Track Event Section - -Track Event Section: -- Section title: "Track Event" with info icon for tooltip -- TRACK EVENT button -> opens a custom CommunityToolkit.Maui popup (not ShowForm) with: - - "Event Name" Entry field (AutomationId: track_event_name_input) - - "Properties (optional, JSON)" Entry field with placeholder hint {"key": "value"} (AutomationId: track_event_props_input) - - Inline red error label "Invalid JSON format" below the props field (hidden by default) - - TRACK confirm button (AutomationId: track_event_confirm_button): - - Does NOT close the modal if props is non-empty and invalid JSON — shows error label instead - - Only closes when name is non-empty AND JSON is valid (or empty) - - If valid, parsed via System.Text.Json into Dictionary; empty props passes null -- Calls OneSignal.User.TrackEvent(name, properties) - -### Prompt 2.13 - Location Section - -Location Section: -- Section title: "Location" with info icon for tooltip -- Location Shared toggle switch: - - Label: "Location Shared" - - Description: "Share device location with OneSignal" - - Sets OneSignal.Location.IsShared = value -- PROMPT LOCATION button - [calls OneSignal.Location.RequestPermission()] - -### Prompt 2.14 - Secondary Page - -Secondary Page (launched by "Next Page" button at bottom of main screen): -- Page title: "Secondary Activity" -- Page content: centered Label "Secondary Activity" using a large font style -- Simple screen, no additional functionality needed -- Navigate using Shell.Current.GoToAsync or NavigationPage push +- Notification permission prompt: call `await viewModel.PromptPushAsync()` in the page's `OnAppearing()` override +- Open browser links via `Launcher.OpenAsync(uri)` +- Loading delay: `await Task.Delay(100)` after setting state +- Navigate to secondary page via `Shell.Current.GoToAsync` or `NavigationPage` push +- Triggers list state: `ObservableCollection>` +- Track Event JSON parsing: `System.Text.Json` into `Dictionary`; show inline red error label on invalid JSON; TRACK button does NOT close the modal when validation fails --- -## Phase 3: View User API Integration - -### Prompt 3.1 - Data Loading Flow - -Loading indicator overlay: -- Full-screen semi-transparent overlay with centered ActivityIndicator -- IsLoading flag in AppViewModel -- Show/hide via IsVisible binding based on IsLoading state -- IMPORTANT: Add 100ms delay after populating data before dismissing loading indicator - - This ensures UI has time to render - - Use await Task.Delay(100) after setting state - -On cold start: -- Check if OneSignal.User.OneSignalId is not null/empty -- If exists: show loading -> call FetchUserDataFromApiAsync() -> populate UI -> delay 100ms -> hide loading -- If null: just show empty state (no loading indicator) - -On login (LOGIN USER / SWITCH USER): -- Show loading indicator immediately -- Call OneSignal.Login(externalUserId) -- Clear old user data (aliases, emails, sms, triggers) -- Wait for User.Changed event callback -- User.Changed calls FetchUserDataFromApiAsync() -- FetchUserDataFromApiAsync() populates UI, delays 100ms, then hides loading - -On logout: -- Show loading indicator -- Call OneSignal.Logout() -- Clear local lists (aliases, emails, sms, triggers) -- Hide loading indicator +## State Management (MVVM) -On User.Changed callback (EventHandler): -- Call FetchUserDataFromApiAsync() to sync with server state -- Update UI with new data (aliases, tags, emails, sms) +AppViewModel extends `ObservableObject` (CommunityToolkit.Mvvm): +- `[ObservableProperty]` fields generate properties + INotifyPropertyChanged +- `[RelayCommand]` methods for actions +- `ObservableCollection` for list state (AliasesList, EmailsList, SmsNumbersList, TagsList, TriggersList) +- Receives `OneSignalRepository` and `PreferencesService` via constructor injection -Note: REST API key is NOT required for fetchUser endpoint. - -### Prompt 3.2 - UserData Model - -public class UserData -{ - public Dictionary Aliases { get; } // From identity object (filter out external_id, onesignal_id) - public Dictionary Tags { get; } // From properties.tags object - public List Emails { get; } // From subscriptions where type=="Email" -> token - public List SmsNumbers { get; } // From subscriptions where type=="SMS" -> token - public string? ExternalId { get; } // From identity.external_id +Register with MAUI DI container: +```csharp +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` - public UserData( - Dictionary aliases, - Dictionary tags, - List emails, - List smsNumbers, - string? externalId) - { ... } - - public static UserData? FromJson(JsonElement json) { ... } -} +Persistence: `Microsoft.Maui.Storage.Preferences` --- -## Phase 4: Info Tooltips - -### Prompt 4.1 - Tooltip Content (Remote) - -Tooltip content is fetched at runtime from the sdk-shared repo. Do NOT bundle a local copy. - -URL: -https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json - -This file is maintained in the sdk-shared repo and shared across all platform demo apps. - -### Prompt 4.2 - Tooltip Helper - -Create TooltipHelper as a singleton: - -public class TooltipHelper -{ - private static readonly TooltipHelper _instance = new(); - public static TooltipHelper Instance => _instance; - - private Dictionary _tooltips = new(); - private bool _initialized = false; - - private const string TooltipUrl = - "https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json"; - - public async Task InitAsync() - { - if (_initialized) return; - try - { - // Fetch tooltip_content.json from TooltipUrl using HttpClient - // Parse JSON into _tooltips dictionary - // On failure (no network, etc.), leave _tooltips empty — tooltips are non-critical - } - catch { } - _initialized = true; - } - - public TooltipData? GetTooltip(string key) => _tooltips.GetValueOrDefault(key); -} +## Reusable Controls -public record TooltipData(string Title, string Description, List? Options); -public record TooltipOption(string Name, string Description); +XAML controls in `Controls/`: +- `SectionCard.xaml` — Frame/Border with title Label, optional info ImageButton, ContentView child (BindableProperty) +- `ToggleRow.xaml` — Label + description Label + Switch in a Grid with IsToggled two-way binding +- `LoadingOverlay.xaml` — AbsoluteLayout overlay with centered ActivityIndicator, IsVisible bound to IsLoading +- `LogView.xaml` — sticky at top, VerticalStackLayout inside ScrollView (not CollectionView), default expanded, Material icons via `mi:MauiIcon` (ExpandLess/ExpandMore for collapse toggle, Delete for clear), auto-scroll via `ScrollView.ScrollToAsync` -### Prompt 4.3 - Tooltip UI Integration +Button styles defined in `App.xaml` (PrimaryButton style, DestructiveButton style), not separate controls. -For each section, pass an info tap Command or event to the SectionCard control: -- SectionCard has an optional info ImageButton that fires the command when tapped -- In MainPage, wire the command to show a TooltipPopup/DisplayAlert +### Dialogs -Example in MainPage.xaml.cs: -void OnAliasesInfoTapped(object sender, EventArgs e) -{ - var tooltip = TooltipHelper.Instance.GetTooltip("aliases"); - if (tooltip != null) - ShowTooltipPopup(tooltip); -} - -void ShowTooltipPopup(TooltipData tooltip) -{ - // Show as DisplayAlert or custom popup overlay - DisplayAlert(tooltip.Title, tooltip.Description, "OK"); -} +Use CommunityToolkit.Maui popup overlays for all flows (do not use `DisplayPromptAsync`): +- Single-input popups (login, email, SMS): one Entry field +- Two-input popups (alias, tag, trigger, custom notification, track event): two Entry fields +- Outcome popup: inline RadioButton type selection (no Picker) + Entry fields +- Multi-pair input: popup overlay via `ShowPopupAsync` with dynamic rows +- Multi-select remove: popup overlay via `ShowPopupAsync` with CheckBox per item +- Close via `page.ClosePopupAsync(result)` +- Two-field single-add dialogs use `DialogInputHelper.ShowPairInput` — renders both fields side by side in a two-column Grid on one row --- -## Phase 5: Data Persistence & Initialization - -### What IS Persisted (Preferences) - -PreferencesService stores (using Microsoft.Maui.Storage.Preferences): -- OneSignal App ID -- Consent required status -- Privacy consent status -- External user ID (for login state restoration) -- Location shared status -- In-app messaging paused status - -### Initialization Flow - -On app startup, state is restored in two layers: - -1. MauiProgram.cs (or App.xaml.cs) restores SDK state from Preferences BEFORE Initialize: - - OneSignal.ConsentRequired = cachedConsentRequired - - OneSignal.ConsentGiven = cachedPrivacyConsent - - OneSignal.Initialize(appId) - Then AFTER Initialize, restores remaining SDK state: - - OneSignal.InAppMessages.Paused = cachedPausedStatus - - OneSignal.Location.IsShared = cachedLocationShared - This ensures consent settings are in place before the SDK initializes. - -2. AppViewModel.LoadInitialStateAsync() reads UI state from the SDK (not Preferences): - - consentRequired from cached prefs (no SDK getter) - - privacyConsentGiven from cached prefs (no SDK getter) - - inAppMessagesPaused from OneSignal.InAppMessages.Paused - - locationShared from OneSignal.Location.IsShared - - externalUserId from OneSignal.User.ExternalId - - appId from PreferencesService (app-level config) - -This two-layer approach ensures: -- The SDK is configured with the user's last preferences before anything else runs -- The ViewModel reads the SDK's actual state as the source of truth for the UI -- The UI always reflects what the SDK reports, not stale cache values - -### What is NOT Persisted (In-Memory Only) +## Theme -AppViewModel holds in memory: -- TriggersList: ObservableCollection> - - Triggers are session-only - - Cleared on app restart - - Used for testing IAM trigger conditions - -- AliasesList: - - Populated from REST API on each session start - - When user adds alias locally, added to list immediately (SDK syncs async) - - Fetched fresh via FetchUserDataFromApiAsync() on login/app start - -- EmailsList, SmsNumbersList: - - Populated from REST API on each session - - Not cached locally - - Fetched fresh via FetchUserDataFromApiAsync() - -- TagsList: - - Can be read from SDK via OneSignal.User.GetTags() - - Also fetched from API for consistency +Implement in `Resources/Styles/`: +- `Colors.xaml` — Color resources mapped from styles.md tokens +- `Styles.xaml` — global implicit/explicit styles for Border, Button, Entry, NavigationPage BarBackgroundColor/BarTextColor, etc. +- `AppSpacing` as static doubles (or `x:Double` resources) for spacing tokens --- -## Phase 6: Testing Values (Appium Compatibility) - -All dialog input fields should be EMPTY by default. -The test automation framework (Appium) will enter these values: +## Log View -- Login Dialog: External User Id = "test" -- Add Alias Dialog: Key = "Test", Value = "Value" -- Add Multiple Aliases Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows) -- Add Email Dialog: Email = "test@onesignal.com" -- Add SMS Dialog: SMS = "123-456-5678" -- Add Tag Dialog: Key = "Test", Value = "Value" -- Add Multiple Tags Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows) -- Add Trigger Dialog: Key = "trigger_key", Value = "trigger_value" -- Add Multiple Triggers Dialog: Key = "trigger_key", Value = "trigger_value" (first row; supports multiple rows) -- Outcome Dialog: Name = "test_outcome", Value = "1.5" -- Track Event Dialog: Name = "test_event", Properties = "{\"key\": \"value\"}" -- Custom Notification Dialog: Title = "Test Title", Body = "Test Body" +- Use `VerticalStackLayout` inside `ScrollView` (100dp container is small, CollectionView is overkill) +- Material icons via `mi:MauiIcon`: `Delete` for trash, `ExpandLess`/`ExpandMore` for collapse toggle +- Collapse/expand toggled in code-behind: `CollapseArrow.Icon = MaterialIcons.ExpandLess/ExpandMore` +- AutomationId on each element (e.g. `AutomationId="log_entry_0_message"`) +- LogManager singleton uses `INotifyPropertyChanged` or event for reactive updates +- Console output via `Debug.WriteLine` --- -## Phase 7: Important Implementation Details - -### Alias Management - -Aliases are managed with a hybrid approach: - -1. On app start/login: Fetched from REST API via FetchUserDataFromApiAsync() -2. When user adds alias locally: - - Call OneSignal.User.AddAlias(label, id) - syncs to server async - - Immediately add to local AliasesList (don't wait for API) - - This ensures instant UI feedback while SDK syncs in background -3. On next app launch: Fresh data from API includes the synced alias +## UserData Model -### Notification Permission +```csharp +public class UserData +{ + public Dictionary Aliases { get; } + public Dictionary Tags { get; } + public List Emails { get; } + public List SmsNumbers { get; } + public string? ExternalId { get; } -Notification permission is automatically requested when the main page loads: -- Call await viewModel.PromptPushAsync() in the page's OnAppearing() override -- This ensures the prompt appears after the user sees the app UI -- PROMPT PUSH button remains as fallback if user initially denied -- Button hidden once OneSignal.Notifications.Permission is true -- Keep Push "Enabled" Switch disabled until permission is granted + public static UserData? FromJson(JsonElement json) { ... } +} +``` --- -## Phase 8: .NET MAUI Architecture - -### Prompt 8.1 - State Management with MVVM - -Use CommunityToolkit.Mvvm for MVVM boilerplate. - -MauiProgram.cs: -- Register AppViewModel and services with the MAUI DI container (builder.Services) -- Initialize OneSignal SDK in MauiProgram.cs before builder.Build() -- Fetch tooltips in the background (non-blocking, fire-and-forget) - -AppViewModel : ObservableObject (CommunityToolkit.Mvvm): -- Holds all UI state as [ObservableProperty] fields (generates properties + INotifyPropertyChanged) -- Exposes [RelayCommand] methods that update state -- Receives OneSignalRepository via constructor injection -- Receives PreferencesService via constructor injection -- ObservableCollection for list state (AliasesList, EmailsList, SmsNumbersList, TagsList, TriggersList) - -### Prompt 8.2 - Reusable Controls - -Create reusable MAUI controls in Controls/: - -SectionCard.xaml: -- Frame/Border with title Label and optional info ImageButton -- ContentView child slot (BindableProperty) -- InfoTappedCommand for tooltips -- Consistent padding and shadow styling - -ToggleRow.xaml: -- Label, optional description Label, Switch -- Grid layout with columns for label group and switch -- IsToggled two-way binding - -ActionButton.xaml (or use Button styles in App.xaml): -- PrimaryButton style (filled, primary color background) -- DestructiveButton style (outlined, red accent) -- Full-width buttons - -ListControls: -- PairItemView (key-value row with optional delete Button) -- SingleItemView (single value row with delete Button) -- EmptyStateView (centered "No items" Label) -- CollapsibleListView (shows 5 items, expandable with "X more" Label) - -LoadingOverlay.xaml: -- Semi-transparent full-screen overlay using AbsoluteLayout -- Centered ActivityIndicator -- IsVisible bound to AppViewModel.IsLoading - -Dialogs — use CommunityToolkit.Maui popup overlays for all app flows (do not use DisplayPromptAsync): -- Single-input dialogs (login, email, SMS): toolkit popup with one field -- Two-input dialogs (tags, triggers, aliases, custom notification, track event): toolkit popup with two fields -- Outcome dialog: toolkit popup with inline RadioButton type selection (no picker) + fields -- Multi-pair input (add multiple tags/triggers/aliases): toolkit popup overlay via ShowPopupAsync -- Multi-select remove (remove selected tags/triggers/aliases): toolkit popup overlay via ShowPopupAsync -- Close popups from button handlers using page.ClosePopupAsync(result) -- Use shared helpers (DialogInputHelper and MultiPairDialogHelper) for consistent layout and ghost action buttons -- Two-field single-add dialogs (add alias, add tag, add trigger): use DialogInputHelper.ShowPairInput — renders both fields side by side in a two-column Grid on one row - -Dialog styling per styles.md (ghost action buttons, popup width, spacing). - -### Prompt 8.3 - Reusable Multi-Pair Popup - -Tags, Aliases, and Triggers all share a reusable MultiPairDialogHelper (static class) -for adding multiple key-value pairs at once. It uses CommunityToolkit.Maui ShowPopupAsync -to show a dialog overlay (not a full-screen page). Close via page.ClosePopupAsync(result). +## Setup Script -Behavior: -- Popup shows as an overlay dialog with a dimmed background, not full-screen -- Starts with one empty key-value row (Key and Value Entries side by side in a Grid) -- "Add Row" Button below the rows adds another empty row; the button is centered horizontally -- BoxView dividers separate each row for visual clarity -- Each row shows an X (close icon) delete Button on the right (hidden when only one row) -- "Add All" Button is disabled until ALL key and value Entry fields in every row are filled -- Validation runs on every TextChanged and after row add/remove -- On "Add All" press, all rows are collected and submitted as a batch -- Batch operations use SDK bulk APIs (AddAliases, AddTags, AddTriggers) - -Used by: -- ADD MULTIPLE button (Aliases section) -> calls viewModel.AddAliasesCommand -- ADD MULTIPLE button (Tags section) -> calls viewModel.AddTagsCommand -- ADD MULTIPLE button (Triggers section) -> calls viewModel.AddTriggersCommand - -### Prompt 8.4 - Reusable Remove Multi Popup - -Tags and Triggers share a reusable MultiSelectRemovePopup control -for selectively removing items from the current list. - -Behavior: -- Accepts the current list of items as IEnumerable> -- Renders one CheckBox per item on the left with just the key as the label (not "key: value") -- User can check 0, 1, or more items -- "Remove (N)" Button shows count of selected items, disabled when none selected -- On confirm, checked items' keys are collected as List and passed to the callback - -Used by: -- REMOVE SELECTED button (Tags section) -> calls viewModel.RemoveSelectedTagsCommand -- REMOVE SELECTED button (Triggers section) -> calls viewModel.RemoveSelectedTriggersCommand - -### Prompt 8.5 - Theme - -Create OneSignal theme in Resources/Styles/ (Colors.xaml and Styles.xaml). - -All colors, spacing, typography, button styles, card styles, and component -specs are defined in the shared style reference: - https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/demo/styles.md - -Implement Colors.xaml with Color resources and Styles.xaml with global -implicit/explicit styles that map the style reference values to MAUI -controls (Border CornerRadius, Button CornerRadius, Entry Border, -NavigationPage BarBackgroundColor/BarTextColor, etc.). - -Also define AppSpacing as static doubles (or x:Double resources) that -expose the spacing tokens from styles.md for use throughout the app. - -### Prompt 8.6 - Log View (Appium-Ready) - -Add collapsible log view at top of screen for debugging and Appium testing. - -Files: -- Services/LogManager.cs - Singleton logger -- Controls/LogView.xaml - Log viewer control with AutomationId labels - -LogManager Features: -- Singleton with INotifyPropertyChanged or event for reactive UI updates -- API: LogManager.Instance.D(tag, message), .I(), .W(), .E() mimics debug log levels -- Also prints to console via Debug.WriteLine for development - -LogView Features: -- Refer to the Logs View section of the shared style reference for layout, colors, and typography -- STICKY at the top of the screen (always visible while scrolling content below) -- Use VerticalStackLayout inside ScrollView instead of CollectionView (100dp container is small) -- Default expanded -- Material delete icon (mi:MauiIcon Icon=Delete) with TapGestureRecognizer for clearing logs -- Collapse/expand toggle uses Material icons (mi:MauiIcon): ExpandLess when expanded, ExpandMore when collapsed; icon is toggled in code-behind via CollapseArrow.Icon = MaterialIcons.ExpandLess/ExpandMore -- Trash icon only visible when entries exist -- Auto-scroll to newest using ScrollView.ScrollToAsync - -Appium AutomationId Labels: -| AutomationId | Description | -|---------------------------|------------------------------------| -| log_view_container | Main container | -| log_view_header | Tappable expand/collapse | -| log_view_count | Shows "(N)" log count | -| log_view_clear_button | Clear all logs | -| log_view_list | Scrollable list area | -| log_view_empty | "No logs yet" state | -| log_entry_{N} | Each log row (N=index) | -| log_entry_{N}_timestamp | Timestamp Label | -| log_entry_{N}_level | D/I/W/E indicator Label | -| log_entry_{N}_message | Log message Label | - -Set AutomationId on each element for Appium accessibility: -