diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..9db250ae --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,191 @@ +name: E2E Tests + +on: + push: + branches: + - rel/** + workflow_dispatch: + inputs: + platform: + description: 'Platform to test' + required: true + default: 'both' + type: choice + options: + - android + - ios + - both + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-android: + if: >- + github.event_name == 'push' || + github.event.inputs.platform == 'android' || + github.event.inputs.platform == 'both' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install MAUI Android workload + run: dotnet workload install maui-android + + - name: Create demo .env + working-directory: examples/demo + run: | + echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env + echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env + echo "E2E_MODE=true" >> .env + + # RuntimeIdentifier=android-arm64 ships native code only for arm64-v8a, + # which is what BrowserStack's modern Android device farm runs. This + # roughly halves the APK size (~30MB → ~19MB) versus the default + # multi-ABI build. + - name: Build release APK + working-directory: examples/demo + run: | + dotnet publish demo.csproj \ + -f net10.0-android \ + -c Release \ + -p:AndroidPackageFormat=apk \ + -p:RuntimeIdentifier=android-arm64 + + - name: Locate APK + id: apk + working-directory: examples/demo + run: | + APK=$(ls bin/Release/net10.0-android/android-arm64/*-Signed.apk | head -n1) + if [ -z "$APK" ]; then + echo "::error::No signed APK produced by dotnet publish" + exit 1 + fi + cp "$APK" demo.apk + echo "path=examples/demo/demo.apk" >> "$GITHUB_OUTPUT" + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: demo-apk + path: ${{ steps.apk.outputs.path }} + retention-days: 1 + compression-level: 0 + + build-ios: + if: >- + github.event_name == 'push' || + github.event.inputs.platform == 'ios' || + github.event.inputs.platform == 'both' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install MAUI iOS workload + run: dotnet workload install maui-ios + + - name: Create demo .env + working-directory: examples/demo + run: | + echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env + echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env + echo "E2E_MODE=true" >> .env + + - name: Set up iOS codesigning + uses: OneSignal/sdk-shared/.github/actions/setup-ios-demo-codesigning@main + with: + p12-base64: ${{ secrets.APPIUM_IOS_DEV_CERT_P12_BASE64 }} + p12-password: ${{ secrets.APPIUM_IOS_DEV_CERT_PASSWORD }} + asc-key-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_KEY_ID }} + asc-issuer-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_ISSUER_ID }} + asc-private-key: ${{ secrets.APPIUM_APP_STORE_CONNECT_PRIVATE_KEY }} + + # RuntimeIdentifier=ios-arm64 is required for a real device build that + # BrowserStack can install. + # Do NOT add -p:UseInterpreter=true here. It shrinks the IPA but breaks + # the Notification Service Extension (image notifications stop working) + # because the interpreter path drops the ObjC class registration for + # the NSE binding in OneSignalSDK.DotNet.iOS. + - name: Build signed IPA + working-directory: examples/demo + run: | + dotnet publish demo.csproj \ + -f net10.0-ios \ + -c Release \ + -p:RuntimeIdentifier=ios-arm64 \ + -p:ArchiveOnBuild=true + + - name: Locate IPA + id: ipa + working-directory: examples/demo + run: | + IPA=$(ls bin/Release/net10.0-ios/ios-arm64/publish/*.ipa | head -n1) + if [ -z "$IPA" ]; then + echo "::error::No IPA produced by dotnet publish" + exit 1 + fi + cp "$IPA" demo.ipa + echo "path=examples/demo/demo.ipa" >> "$GITHUB_OUTPUT" + + - name: Verify aps-environment in IPA + working-directory: examples/demo + run: | + unzip -oq demo.ipa -d /tmp/ipa + APP=$(ls -d /tmp/ipa/Payload/*.app | head -n1) + codesign -d --entitlements - "$APP" 2>&1 | tee /tmp/entitlements.txt + if ! grep -q 'aps-environment' /tmp/entitlements.txt; then + echo "::error::Built IPA is missing aps-environment entitlement; push subscription will not work" + exit 1 + fi + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: demo-ipa + path: ${{ steps.ipa.outputs.path }} + retention-days: 1 + compression-level: 0 + + e2e-android: + needs: build-android + uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main + secrets: inherit + with: + platform: android + app-artifact: demo-apk + app-filename: demo.apk + sdk-type: dotnet + build-name: dotnet-android-${{ github.ref_name }}-${{ github.run_number }} + + e2e-ios: + needs: build-ios + uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main + secrets: inherit + with: + platform: ios + app-artifact: demo-ipa + app-filename: demo.ipa + sdk-type: dotnet + build-name: dotnet-ios-${{ github.ref_name }}-${{ github.run_number }} diff --git a/OneSignalSDK.DotNet.Android/Utilities/ToNativeConversion.cs b/OneSignalSDK.DotNet.Android/Utilities/ToNativeConversion.cs index 92594daf..fad341f5 100644 --- a/OneSignalSDK.DotNet.Android/Utilities/ToNativeConversion.cs +++ b/OneSignalSDK.DotNet.Android/Utilities/ToNativeConversion.cs @@ -102,9 +102,21 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel) var javaMap = new Dictionary(); foreach (var kvp in dict) { + if (kvp.Value == null) + { + javaMap[kvp.Key] = null!; + continue; + } + var javaValue = ToJavaObject(kvp.Value); - if (javaValue != null) - javaMap[kvp.Key] = javaValue; + if (javaValue == null) + // ToJavaObject returns null for types it doesn't know how to + // marshal (e.g. a POCO or DateTime in a tags dict). Drop the + // entry silently to mirror the iOS path; the caller will + // notice the missing value in OneSignal and fix the input. + continue; + + javaMap[kvp.Key] = javaValue; } return javaMap; } @@ -117,7 +129,18 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel) var javaMap = new Java.Util.HashMap(); foreach (var kvp in dict) { - javaMap.Put(kvp.Key, ToJavaObject(kvp.Value)); + if (kvp.Value == null) + { + javaMap.Put(kvp.Key, null); + continue; + } + + var javaValue = ToJavaObject(kvp.Value); + if (javaValue == null) + // Unsupported value type - skip silently (see DictToJavaMap). + continue; + + javaMap.Put(kvp.Key, javaValue); } return javaMap; } @@ -130,7 +153,20 @@ public static Com.OneSignal.Android.Debug.LogLevel ToLogLevel(LogLevel logLevel) var javaList = new Java.Util.ArrayList(); foreach (var item in list) { - javaList.Add(ToJavaObject(item)); + if (item == null) + { + javaList.Add(null); + continue; + } + + var javaValue = ToJavaObject(item); + if (javaValue == null) + // Unsupported item type - skip silently (see DictToJavaMap). + // Index alignment with the source list isn't preserved; the + // caller is expected to use supported types throughout. + continue; + + javaList.Add(javaValue); } return javaList; } diff --git a/examples/build.md b/examples/build.md index b219cfef..3a633f2a 100644 --- a/examples/build.md +++ b/examples/build.md @@ -75,9 +75,9 @@ SDK reference via project reference: --- -## OneSignal Repository (SDK API Mapping) +## SDK API Mapping -Use the static `OneSignal` class from `OneSignalSDK.DotNet`: +Call the static `OneSignal` class from `OneSignalSDK.DotNet` directly inside `AppViewModel`. There is no repository wrapper. | Operation | SDK Call | |---|---| @@ -178,13 +178,14 @@ 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 +- Receives `PreferencesService` and `OneSignalApiService` via constructor injection +- Calls `OneSignal.*` static APIs directly (no repository layer) Register with MAUI DI container: ```csharp builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); ``` Persistence: `Microsoft.Maui.Storage.Preferences` @@ -196,8 +197,7 @@ Persistence: `Microsoft.Maui.Storage.Preferences` 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` +- `LoadingState.xaml` — Inline ActivityIndicator shown by list-bearing sections (Aliases, Emails, SMS, Tags) while `AppViewModel.IsLoading` is true and the list is empty Button styles defined in `App.xaml` (PrimaryButton style, DestructiveButton style), not separate controls. @@ -223,14 +223,9 @@ Implement in `Resources/Styles/`: --- -## Log View +## Logging -- 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` +Use `System.Diagnostics.Debug.WriteLine` for debug logging; do not build a custom in-app log viewer. --- @@ -275,20 +270,16 @@ examples/demo/ ├── Services/ │ ├── OneSignalApiService.cs # REST API client (HttpClient) │ ├── PreferencesService.cs # Preferences wrapper -│ ├── TooltipHelper.cs # Fetches tooltips from remote URL -│ └── LogManager.cs # Singleton logger with INotifyPropertyChanged -├── Repositories/ -│ └── OneSignalRepository.cs # Centralized SDK calls +│ └── TooltipHelper.cs # Fetches tooltips from remote URL ├── ViewModels/ │ └── AppViewModel.cs # ObservableObject with all UI state ├── Pages/ -│ ├── MainPage.xaml / MainPage.xaml.cs # Main scrollable page (includes LogView) +│ ├── MainPage.xaml / MainPage.xaml.cs # Main scrollable page │ └── SecondaryPage.xaml / .cs # "Secondary Activity" page ├── Controls/ │ ├── SectionCard.xaml # Card with title and info icon │ ├── ToggleRow.xaml # Label + Switch -│ ├── LoadingOverlay.xaml # Full-screen loading spinner -│ ├── LogView.xaml # Collapsible log viewer (Appium-ready) +│ ├── LoadingState.xaml # Inline list loading spinner │ └── Sections/ │ ├── AppSection.xaml │ ├── PushSection.xaml @@ -301,7 +292,7 @@ examples/demo/ │ ├── TagsSection.xaml │ ├── OutcomesSection.xaml │ ├── TriggersSection.xaml -│ ├── TrackEventSection.xaml +│ ├── CustomEventsSection.xaml │ └── LocationSection.xaml ├── Resources/ │ ├── Styles/ diff --git a/examples/demo/.env.example b/examples/demo/.env.example index 674a938f..b0c98cee 100644 --- a/examples/demo/.env.example +++ b/examples/demo/.env.example @@ -1 +1,4 @@ +# Default App ID (used when ONESIGNAL_APP_ID is empty or missing): 77e32082-ea27-42e3-a898-c72e141824ef +ONESIGNAL_APP_ID=your-onesignal-app-id ONESIGNAL_API_KEY=your_rest_api_key +E2E_MODE=false diff --git a/examples/demo/Controls/DialogInputHelper.cs b/examples/demo/Controls/DialogInputHelper.cs index 8ec211e2..b382634b 100644 --- a/examples/demo/Controls/DialogInputHelper.cs +++ b/examples/demo/Controls/DialogInputHelper.cs @@ -54,11 +54,11 @@ public static class DialogInputHelper Key = "value", Placeholder = placeholder, AutomationId = entryAutomationId, - Keyboard = keyboard ?? Keyboard.Default, + Keyboard = keyboard ?? Keyboard.Plain, }, }, confirmText, - confirmAutomationId + confirmAutomationId ?? "singleinput_confirm_button" ); return result != null && result.TryGetValue("value", out var value) ? value : null; } @@ -93,7 +93,7 @@ public static class DialogInputHelper title, row, confirmText, - confirmAutomationId, + confirmAutomationId ?? "singlepair_confirm_button", () => new Dictionary(StringComparer.Ordinal) { @@ -181,12 +181,18 @@ Func> getResult return result?.Result; } + // IsTextPredictionEnabled / IsSpellCheckEnabled disabled to drop iOS's + // QuickType prediction strip (frees vertical space above the keyboard) + // and avoid autocorrect mangling test input like `dotnet_ios`. Mirrors + // react-native-onesignal/examples/demo/src/theme.ts > AppInputProps. private static Entry BuildEntry(DialogInputField field) => new() { Placeholder = field.Placeholder, AutomationId = field.AutomationId ?? string.Empty, Keyboard = field.Keyboard, + IsTextPredictionEnabled = false, + IsSpellCheckEnabled = false, }; internal static Button ActionButton(string text, string? automationId = null) => diff --git a/examples/demo/Controls/LoadingOverlay.xaml b/examples/demo/Controls/LoadingOverlay.xaml deleted file mode 100644 index 6c9770fc..00000000 --- a/examples/demo/Controls/LoadingOverlay.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/examples/demo/Controls/LoadingOverlay.xaml.cs b/examples/demo/Controls/LoadingOverlay.xaml.cs deleted file mode 100644 index 24a2993e..00000000 --- a/examples/demo/Controls/LoadingOverlay.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OneSignalDemo.Controls; - -public partial class LoadingOverlay : ContentView -{ - public LoadingOverlay() - { - InitializeComponent(); - } -} diff --git a/examples/demo/Controls/LoadingState.xaml b/examples/demo/Controls/LoadingState.xaml new file mode 100644 index 00000000..4bbdf174 --- /dev/null +++ b/examples/demo/Controls/LoadingState.xaml @@ -0,0 +1,16 @@ + + + + diff --git a/examples/demo/Controls/LoadingState.xaml.cs b/examples/demo/Controls/LoadingState.xaml.cs new file mode 100644 index 00000000..e8525eb2 --- /dev/null +++ b/examples/demo/Controls/LoadingState.xaml.cs @@ -0,0 +1,15 @@ +namespace OneSignalDemo.Controls; + +public partial class LoadingState : ContentView +{ + public LoadingState() + { + InitializeComponent(); + } + + public LoadingState(string automationId) + : this() + { + AutomationId = automationId; + } +} diff --git a/examples/demo/Controls/LogView.xaml b/examples/demo/Controls/LogView.xaml deleted file mode 100644 index 4fb13749..00000000 --- a/examples/demo/Controls/LogView.xaml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/examples/demo/Controls/LogView.xaml.cs b/examples/demo/Controls/LogView.xaml.cs deleted file mode 100644 index b1110256..00000000 --- a/examples/demo/Controls/LogView.xaml.cs +++ /dev/null @@ -1,99 +0,0 @@ -using MauiIcons.Core; -using MauiIcons.Material; -using OneSignalDemo.Services; - -namespace OneSignalDemo.Controls; - -public partial class LogView : ContentView -{ - private bool _isExpanded = true; - private readonly List _logRows = new(); - - public LogView() - { - InitializeComponent(); - LogManager.Instance.LogAdded += OnLogAdded; - UpdateCount(); - } - - private void OnLogAdded(object? sender, EventArgs e) - { - MainThread.BeginInvokeOnMainThread(RebuildLogList); - } - - private void RebuildLogList() - { - var logs = LogManager.Instance.Logs; - LogList.Children.Clear(); - _logRows.Clear(); - - for (int i = 0; i < logs.Count; i++) - { - var entry = logs[i]; - var row = new HorizontalStackLayout { Spacing = 4, Padding = new Thickness(0, 1) }; - - var ts = new Label - { - Text = entry.Timestamp, - TextColor = Color.FromArgb("#676E7B"), - FontSize = 11, - FontFamily = "DroidSansMono", - VerticalOptions = LayoutOptions.Center, - AutomationId = $"log_entry_{i}_timestamp", - }; - var level = new Label - { - Text = entry.Level, - TextColor = entry.LevelColor, - FontSize = 11, - FontFamily = "DroidSansMono", - FontAttributes = FontAttributes.Bold, - VerticalOptions = LayoutOptions.Center, - Margin = new Thickness(4, 0), - AutomationId = $"log_entry_{i}_level", - }; - var msg = new Label - { - Text = entry.Message, - TextColor = Colors.White, - FontSize = 11, - FontFamily = "DroidSansMono", - VerticalOptions = LayoutOptions.Center, - AutomationId = $"log_entry_{i}_message", - }; - - row.Children.Add(ts); - row.Children.Add(level); - row.Children.Add(msg); - row.AutomationId = $"log_entry_{i}"; - - LogList.Children.Add(row); - _logRows.Add(row); - } - - UpdateCount(); - } - - private void UpdateCount() - { - var count = LogManager.Instance.Logs.Count; - LogCountLabel.Text = $"({count})"; - ClearIcon.IsVisible = count > 0; - EmptyLabel.IsVisible = count == 0; - } - - private void OnHeaderTapped(object? sender, TappedEventArgs e) - { - _isExpanded = !_isExpanded; - LogScrollView.IsVisible = _isExpanded; - CollapseArrow.Icon = _isExpanded ? MaterialIcons.ExpandLess : MaterialIcons.ExpandMore; - } - - private void OnClearClicked(object? sender, EventArgs e) - { - LogManager.Instance.Clear(); - LogList.Children.Clear(); - _logRows.Clear(); - UpdateCount(); - } -} diff --git a/examples/demo/Controls/MultiPairDialogHelper.cs b/examples/demo/Controls/MultiPairDialogHelper.cs index 2c77804a..0f8e8552 100644 --- a/examples/demo/Controls/MultiPairDialogHelper.cs +++ b/examples/demo/Controls/MultiPairDialogHelper.cs @@ -29,15 +29,30 @@ void AddRow() } ); + // Keyboard.Plain + explicit prediction/spellcheck off: + // - Drops the iOS QuickType prediction strip (~40pt of keyboard + // height), giving the popup's confirm button more clearance + // above the keyboard. + // - Stops autocapitalize from mangling snake_case test input + // like `test_label_2`. + // - Mirrors RN's AppInputProps (autoCorrect: false, + // autoCapitalize: 'none', autoComplete: 'off') in + // react-native-onesignal/examples/demo/src/theme.ts. var keyEntry = new Entry { Placeholder = keyPlaceholder, - AutomationId = $"multi_pair_key_{rows.Count}", + AutomationId = $"multipair_key_{rows.Count}", + Keyboard = Keyboard.Plain, + IsTextPredictionEnabled = false, + IsSpellCheckEnabled = false, }; var valueEntry = new Entry { Placeholder = valuePlaceholder, - AutomationId = $"multi_pair_value_{rows.Count}", + AutomationId = $"multipair_value_{rows.Count}", + Keyboard = Keyboard.Plain, + IsTextPredictionEnabled = false, + IsSpellCheckEnabled = false, }; var capturedRow = (keyEntry, valueEntry); rows.Add(capturedRow); @@ -93,10 +108,11 @@ void AddRow() TextColor = Color.FromArgb("#E54B4D"), HorizontalOptions = LayoutOptions.Center, Padding = new Thickness(0, 8), + AutomationId = "multipair_add_row_button", }; - var cancelButton = DialogInputHelper.ActionButton("Cancel", "multi_pair_cancel_button"); - var addAllButton = DialogInputHelper.ActionButton("Add All", "multi_pair_add_all_button"); + var cancelButton = DialogInputHelper.ActionButton("Cancel", "multipair_cancel_button"); + var addAllButton = DialogInputHelper.ActionButton("Add All", "multipair_confirm_button"); addRowButton.Clicked += (s, e) => AddRow(); cancelButton.Clicked += async (s, e) => await parentPage.ClosePopupAsync(); @@ -111,7 +127,10 @@ void AddRow() if (!string.IsNullOrEmpty(k)) result[k] = v; } - await parentPage.ClosePopupAsync(result.Count > 0 ? result : null); + // Always close with a non-null dictionary; empty-input semantics + // are handled by callers checking `pairs.Count == 0`. Mirrors + // the singlepair flow in DialogInputHelper.ShowPopupAsync. + await parentPage.ClosePopupAsync(result); }; var content = new VerticalStackLayout @@ -140,7 +159,7 @@ void AddRow() }, }; - var result2 = await parentPage.ShowPopupAsync?>( + var result2 = await parentPage.ShowPopupAsync>( content, DialogInputHelper.DialogOptions ); @@ -158,7 +177,11 @@ IEnumerable keys foreach (var key in keys) { - var cb = new CheckBox { Color = Color.FromArgb("#E54B4D") }; + var cb = new CheckBox + { + Color = Color.FromArgb("#E54B4D"), + AutomationId = $"remove_checkbox_{key}", + }; checkboxes.Add((cb, key)); var row = new HorizontalStackLayout { Spacing = 8 }; row.Children.Add(cb); @@ -173,10 +196,10 @@ IEnumerable keys itemsLayout.Children.Add(row); } - var cancelButton = DialogInputHelper.ActionButton("Cancel", "multi_select_cancel_button"); + var cancelButton = DialogInputHelper.ActionButton("Cancel", "multiselect_cancel_button"); var removeButton = DialogInputHelper.ActionButtonDisabled( "Remove (0)", - "multi_select_remove_button" + "multiselect_confirm_button" ); void UpdateButton() diff --git a/examples/demo/Controls/Sections/AliasesSection.xaml b/examples/demo/Controls/Sections/AliasesSection.xaml index 89c9d5bb..c3bc9386 100644 --- a/examples/demo/Controls/Sections/AliasesSection.xaml +++ b/examples/demo/Controls/Sections/AliasesSection.xaml @@ -5,7 +5,7 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" x:Class="OneSignalDemo.Controls.Sections.AliasesSection" > - + @@ -31,7 +32,7 @@ Text="No aliases added" Style="{StaticResource EmptyStateStyle}" Padding="16" - AutomationId="aliases_empty_label" + AutomationId="aliases_empty" /> diff --git a/examples/demo/Controls/Sections/AliasesSection.xaml.cs b/examples/demo/Controls/Sections/AliasesSection.xaml.cs index 177135ce..5e7b447b 100644 --- a/examples/demo/Controls/Sections/AliasesSection.xaml.cs +++ b/examples/demo/Controls/Sections/AliasesSection.xaml.cs @@ -1,6 +1,4 @@ using System.Collections.Specialized; -using CommunityToolkit.Maui.Alerts; -using CommunityToolkit.Maui.Core; using OneSignalDemo.Controls; using OneSignalDemo.ViewModels; @@ -24,6 +22,11 @@ public void Initialize(AppViewModel viewModel, Page parentPage) _parentPage = parentPage; viewModel.AliasesList.CollectionChanged += (s, e) => RebuildList(); + viewModel.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(AppViewModel.IsLoading)) + RebuildList(); + }; RebuildList(); } @@ -34,7 +37,10 @@ private void RebuildList() if (list == null || list.Count == 0) { - AliasListContainer.Children.Add(EmptyLabel); + if (_viewModel?.IsLoading == true) + AliasListContainer.Children.Add(new LoadingState("aliases_loading")); + else + AliasListContainer.Children.Add(EmptyLabel); return; } @@ -55,13 +61,21 @@ private void RebuildList() first = false; var row = new VerticalStackLayout { Padding = new Thickness(12, 4), Spacing = 2 }; - row.Children.Add(new Label { Text = alias.Key, FontSize = 14 }); + row.Children.Add( + new Label + { + Text = alias.Key, + FontSize = 14, + AutomationId = $"aliases_pair_key_{alias.Key}", + } + ); row.Children.Add( new Label { Text = alias.Value, FontSize = 12, TextColor = Color.FromArgb("#757575"), + AutomationId = $"aliases_pair_value_{alias.Key}", } ); @@ -102,7 +116,6 @@ private async void OnAddClicked(object? sender, EventArgs e) return; _viewModel.AddAlias(new KeyValuePair(label, id)); - await Toast.Make($"Alias added: {label}", ToastDuration.Short).Show(); } private async void OnAddMultipleClicked(object? sender, EventArgs e) @@ -114,7 +127,6 @@ private async void OnAddMultipleClicked(object? sender, EventArgs e) return; _viewModel.AddAliases(pairs); - await Toast.Make($"{pairs.Count} alias(es) added", ToastDuration.Short).Show(); } private async Task?> ShowMultiPairDialog( diff --git a/examples/demo/Controls/Sections/AppSection.xaml b/examples/demo/Controls/Sections/AppSection.xaml index d3b9326e..84bb839e 100644 --- a/examples/demo/Controls/Sections/AppSection.xaml +++ b/examples/demo/Controls/Sections/AppSection.xaml @@ -5,7 +5,7 @@ x:Class="OneSignalDemo.Controls.Sections.AppSection" x:Name="root" > - + diff --git a/examples/demo/Controls/Sections/TrackEventSection.xaml b/examples/demo/Controls/Sections/CustomEventsSection.xaml similarity index 81% rename from examples/demo/Controls/Sections/TrackEventSection.xaml rename to examples/demo/Controls/Sections/CustomEventsSection.xaml index f4166467..59cb85a4 100644 --- a/examples/demo/Controls/Sections/TrackEventSection.xaml +++ b/examples/demo/Controls/Sections/CustomEventsSection.xaml @@ -3,13 +3,13 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" - x:Class="OneSignalDemo.Controls.Sections.TrackEventSection" + x:Class="OneSignalDemo.Controls.Sections.CustomEventsSection" > - + diff --git a/examples/demo/Controls/Sections/TrackEventSection.xaml.cs b/examples/demo/Controls/Sections/CustomEventsSection.xaml.cs similarity index 74% rename from examples/demo/Controls/Sections/TrackEventSection.xaml.cs rename to examples/demo/Controls/Sections/CustomEventsSection.xaml.cs index 17dfcc9d..ec82c623 100644 --- a/examples/demo/Controls/Sections/TrackEventSection.xaml.cs +++ b/examples/demo/Controls/Sections/CustomEventsSection.xaml.cs @@ -8,14 +8,14 @@ namespace OneSignalDemo.Controls.Sections; -public partial class TrackEventSection : ContentView +public partial class CustomEventsSection : ContentView { private AppViewModel? _viewModel; private Page? _parentPage; public event EventHandler? InfoTapped; - public TrackEventSection() + public CustomEventsSection() { InitializeComponent(); } @@ -36,7 +36,7 @@ private async void OnTrackEventClicked(object? sender, EventArgs e) return; _viewModel.TrackEvent(result.Value.name, result.Value.properties); - await Toast.Make($"Event tracked: {result.Value.name}", ToastDuration.Short).Show(); + await Snackbar.Make($"Event tracked: {result.Value.name}").Show(); } private static async Task<( @@ -44,16 +44,12 @@ private async void OnTrackEventClicked(object? sender, EventArgs e) Dictionary? properties )?> ShowTrackEventPopup(Page parentPage) { - var nameEntry = new Entry - { - Placeholder = "Event name", - AutomationId = "track_event_name_input", - }; + var nameEntry = new Entry { Placeholder = "Event name", AutomationId = "event_name_input" }; var propsEntry = new Entry { Placeholder = "{\"key\": \"value\"} (optional)", - AutomationId = "track_event_props_input", + AutomationId = "event_properties_input", }; var errorLabel = new Label @@ -65,7 +61,7 @@ private async void OnTrackEventClicked(object? sender, EventArgs e) }; var cancelButton = DialogInputHelper.ActionButton("Cancel"); - var confirmButton = DialogInputHelper.ActionButton("Track", "track_event_confirm_button"); + var confirmButton = DialogInputHelper.ActionButton("Track", "event_track_button"); (string name, Dictionary? properties)? popupResult = null; @@ -82,10 +78,13 @@ private async void OnTrackEventClicked(object? sender, EventArgs e) { try { - var doc = JsonDocument.Parse(propsText); + using var doc = JsonDocument.Parse(propsText); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + throw new JsonException("Root must be a JSON object"); + properties = new Dictionary(); foreach (var prop in doc.RootElement.EnumerateObject()) - properties[prop.Name] = prop.Value.GetString() ?? string.Empty; + properties[prop.Name] = JsonElementToObject(prop.Value)!; errorLabel.IsVisible = false; } catch @@ -137,4 +136,19 @@ private async void OnTrackEventClicked(object? sender, EventArgs e) } private void OnInfoTapped(object? sender, EventArgs e) => InfoTapped?.Invoke(this, e); + + private static object? JsonElementToObject(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Object => element + .EnumerateObject() + .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)!), + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), + _ => null, + }; } diff --git a/examples/demo/Controls/Sections/EmailsSection.xaml b/examples/demo/Controls/Sections/EmailsSection.xaml index 23b468cb..c12c591b 100644 --- a/examples/demo/Controls/Sections/EmailsSection.xaml +++ b/examples/demo/Controls/Sections/EmailsSection.xaml @@ -5,7 +5,7 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" x:Class="OneSignalDemo.Controls.Sections.EmailsSection" > - + @@ -31,7 +32,7 @@ Text="No emails added" Style="{StaticResource EmptyStateStyle}" Padding="16" - AutomationId="emails_empty_label" + AutomationId="emails_empty" /> diff --git a/examples/demo/Controls/Sections/EmailsSection.xaml.cs b/examples/demo/Controls/Sections/EmailsSection.xaml.cs index dc867d7b..04645914 100644 --- a/examples/demo/Controls/Sections/EmailsSection.xaml.cs +++ b/examples/demo/Controls/Sections/EmailsSection.xaml.cs @@ -22,6 +22,11 @@ public void Initialize(AppViewModel viewModel, Page parentPage) _viewModel = viewModel; _parentPage = parentPage; viewModel.EmailsList.CollectionChanged += (s, e) => RebuildList(); + viewModel.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(AppViewModel.IsLoading)) + RebuildList(); + }; RebuildList(); } @@ -32,7 +37,10 @@ private void RebuildList() if (list == null || list.Count == 0) { - EmailListContainer.Children.Add(EmptyLabel); + if (_viewModel?.IsLoading == true) + EmailListContainer.Children.Add(new LoadingState("emails_loading")); + else + EmailListContainer.Children.Add(EmptyLabel); return; } @@ -72,6 +80,7 @@ private void RebuildList() Text = email, FontSize = 14, VerticalOptions = LayoutOptions.Center, + AutomationId = $"emails_value_{email}", } ); @@ -83,6 +92,7 @@ private void RebuildList() Padding = new Thickness(8, 0), FontSize = 18, HeightRequest = 40, + AutomationId = $"emails_remove_{email}", }; deleteBtn.Clicked += (s, e) => _viewModel?.RemoveEmail(captured); Grid.SetColumn(deleteBtn, 1); diff --git a/examples/demo/Controls/Sections/InAppSection.xaml b/examples/demo/Controls/Sections/InAppSection.xaml index 455267a8..385536a8 100644 --- a/examples/demo/Controls/Sections/InAppSection.xaml +++ b/examples/demo/Controls/Sections/InAppSection.xaml @@ -5,7 +5,7 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" x:Class="OneSignalDemo.Controls.Sections.InAppSection" > - + @@ -35,7 +36,7 @@ x:Name="IamPausedSwitch" IsToggled="False" Toggled="OnIamPausedToggled" - AutomationId="iam_paused_switch" + AutomationId="pause_iam_toggle" /> diff --git a/examples/demo/Controls/Sections/LiveActivitiesSection.xaml b/examples/demo/Controls/Sections/LiveActivitiesSection.xaml index b08979a8..b730be25 100644 --- a/examples/demo/Controls/Sections/LiveActivitiesSection.xaml +++ b/examples/demo/Controls/Sections/LiveActivitiesSection.xaml @@ -5,7 +5,7 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" x:Class="OneSignalDemo.Controls.Sections.LiveActivitiesSection" > - + @@ -63,7 +64,7 @@ BackgroundColor="Transparent" TextColor="#212121" FontSize="14" - AutomationId="live_activity_order_input" + AutomationId="live_activity_order_number_input" TextChanged="OnOrderNumberChanged" /> diff --git a/examples/demo/Controls/Sections/LocationSection.xaml b/examples/demo/Controls/Sections/LocationSection.xaml index 0a7618cb..d29f46a0 100644 --- a/examples/demo/Controls/Sections/LocationSection.xaml +++ b/examples/demo/Controls/Sections/LocationSection.xaml @@ -5,7 +5,7 @@ xmlns:mi="http://www.aathifmahir.com/dotnet/2022/maui/icons" x:Class="OneSignalDemo.Controls.Sections.LocationSection" > - + @@ -38,7 +39,7 @@ x:Name="LocationSharedSwitch" IsToggled="False" Toggled="OnLocationSharedToggled" - AutomationId="location_shared_switch" + AutomationId="location_shared_toggle" /> @@ -49,5 +50,11 @@ Clicked="OnPromptLocationClicked" AutomationId="prompt_location_button" /> +