diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fcccc7f..6d0287a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,3 +35,29 @@ jobs:
channel: 'stable'
- run: cd packages/sdk-flutter && flutter pub get
- run: cd packages/sdk-flutter && flutter analyze
+
+ maui:
+ name: .NET MAUI SDK
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+ - name: Install MAUI workloads
+ run: dotnet workload install android ios
+ - name: Download Screeb iOS XCFramework
+ run: |
+ SCREEB_IOS_URL=$(curl -sL https://api.github.com/repos/ScreebApp/sdk-ios-public/releases/latest | python3 -c "import sys,json; print(next(a['browser_download_url'] for a in json.load(sys.stdin)['assets'] if a['name']=='Screeb.zip'))")
+ curl -sL "$SCREEB_IOS_URL" -o /tmp/Screeb.zip
+ unzip -q /tmp/Screeb.zip -d /tmp/screeb_ios
+ mkdir -p packages/sdk-maui/native/ios
+ cp -r /tmp/screeb_ios/Screeb.xcframework packages/sdk-maui/native/ios/
+ - name: Restore
+ run: dotnet restore packages/sdk-maui/ScreebMaui.csproj
+ - name: Build Android
+ run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android --no-restore
+ - name: Build iOS
+ run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-ios --no-restore
+ - name: Run unit tests
+ run: dotnet test packages/sdk-maui/tests/ScreebUtilsTests.csproj
diff --git a/.github/workflows/publish-maui.yml b/.github/workflows/publish-maui.yml
new file mode 100644
index 0000000..6edd3d1
--- /dev/null
+++ b/.github/workflows/publish-maui.yml
@@ -0,0 +1,42 @@
+name: Publish Screeb.Maui
+
+on:
+ push:
+ tags:
+ - 'sdk-maui/v*'
+
+jobs:
+ publish:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Install MAUI workloads
+ run: dotnet workload install android ios
+
+ - name: Download Screeb iOS XCFramework
+ run: |
+ SCREEB_IOS_URL=$(curl -sL https://api.github.com/repos/ScreebApp/sdk-ios-public/releases/latest | python3 -c "import sys,json; print(next(a['browser_download_url'] for a in json.load(sys.stdin)['assets'] if a['name']=='Screeb.zip'))")
+ curl -sL "$SCREEB_IOS_URL" -o /tmp/Screeb.zip
+ unzip -q /tmp/Screeb.zip -d /tmp/screeb_ios
+ mkdir -p packages/sdk-maui/native/ios
+ cp -r /tmp/screeb_ios/Screeb.xcframework packages/sdk-maui/native/ios/
+
+ - name: Restore
+ run: dotnet restore packages/sdk-maui/ScreebMaui.csproj
+
+ - name: Build Android
+ run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-android -c Release --no-restore
+
+ - name: Build iOS
+ run: dotnet build packages/sdk-maui/ScreebMaui.csproj -f net9.0-ios -c Release --no-restore
+
+ - name: Pack
+ run: dotnet pack packages/sdk-maui/ScreebMaui.csproj -c Release --no-build -o ./nupkg
+
+ - name: Push to NuGet
+ run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/.gitignore b/.gitignore
index cae1ee9..2c00c19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,14 @@ yarn-error.log*
.config.env
.vscode
+
+# MAUI SDK - native binaries (downloaded at build time)
+packages/sdk-maui/native/ios/Screeb.xcframework/
+packages/sdk-maui/native/ios/Screeb.zip
+packages/sdk-maui/native/ios/sdk-ios-public-*/
+
+# .NET build artifacts
+packages/sdk-maui/bin/
+packages/sdk-maui/obj/
+examples/example-maui/bin/
+examples/example-maui/obj/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9ea4ab3..26dc38f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -52,5 +52,8 @@ npx lerna version --scope=@screeb/sdk-browser
# Flutter
# 1. Bump version in packages/sdk-flutter/pubspec.yaml
-# 2. git tag sdk-flutter/vX.Y.Z && git push --tags
+git tag sdk-flutter/vX.Y.Z && git push --tags
+
+# MAUI (NuGet)
+git tag sdk-maui/v0.1.0 && git push --tags
```
diff --git a/README.md b/README.md
index 57f39a1..0b3c64d 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Public SDKs for [Screeb](https://screeb.app) — the Product Discovery platform.
| Vue | [`@screeb/sdk-vue`](packages/sdk-vue) | [npm](https://www.npmjs.com/package/@screeb/sdk-vue) | [Install](https://developers.screeb.app/sdk-vue/install) |
| Ionic | Uses `@screeb/sdk-angular` / `@screeb/sdk-react` / `@screeb/sdk-browser` | — | [Install](https://developers.screeb.app/sdk-js/sdk-ionic) |
| React Native | [`@screeb/react-native`](packages/sdk-reactnative) | [npm](https://www.npmjs.com/package/@screeb/react-native) | [Install](https://developers.screeb.app/sdk-react-native/install) |
+| .NET MAUI | [`Screeb.Maui`](packages/sdk-maui) | [NuGet](https://www.nuget.org/packages/Screeb.Maui) | [Install](https://developers.screeb.app/sdk-maui/install) |
| Flutter | [`plugin_screeb`](packages/sdk-flutter) | [pub.dev](https://pub.dev/packages/plugin_screeb) | [Install](https://developers.screeb.app/sdk-flutter/install) |
| iOS | Closed source ([`sdk-ios-public`](https://github.com/ScreebApp/sdk-ios-public) — SPM mirror) | [SPM](https://github.com/ScreebApp/sdk-ios-public) | [Install](https://developers.screeb.app/sdk-ios/install) |
| Android | Closed source | [Maven](https://central.sonatype.com/artifact/app.screeb.sdk/survey) | [Install](https://developers.screeb.app/sdk-android/install) |
@@ -29,6 +30,7 @@ Public SDKs for [Screeb](https://screeb.app) — the Product Discovery platform.
| Ionic | Angular 16 + Capacitor | [`examples/example-ionic`](examples/example-ionic) |
| Expo | React Native + Expo | [`examples/example-expo`](examples/example-expo) |
| React Native | React Native CLI | [`examples/example-reactnative`](examples/example-reactnative) |
+| .NET MAUI | .NET MAUI | [`examples/example-maui`](examples/example-maui) |
| Flutter | Flutter | [`examples/example-flutter`](examples/example-flutter) |
| Android | Android (Kotlin) | [`examples/example-android`](examples/example-android) |
| iOS | iOS (Swift) | [`examples/example-ios`](examples/example-ios) |
diff --git a/commitlint.config.js b/commitlint.config.js
index 3742de1..464f831 100644
--- a/commitlint.config.js
+++ b/commitlint.config.js
@@ -13,7 +13,9 @@ module.exports = {
"sdk-angular",
"example-angular",
"sdk-vue",
- "example-vue"
+ "example-vue",
+ "sdk-maui",
+ "example-maui"
]],
"scope-empty": [2, "never"],
"scope-min-length": [2, "always", 1],
diff --git a/examples/example-maui/App.xaml b/examples/example-maui/App.xaml
new file mode 100644
index 0000000..54475ab
--- /dev/null
+++ b/examples/example-maui/App.xaml
@@ -0,0 +1,10 @@
+
+
+
+
+ #6200EE
+
+
+
diff --git a/examples/example-maui/App.xaml.cs b/examples/example-maui/App.xaml.cs
new file mode 100644
index 0000000..dd7d14b
--- /dev/null
+++ b/examples/example-maui/App.xaml.cs
@@ -0,0 +1,27 @@
+using static Screeb.Maui.Screeb;
+
+namespace ExampleMaui;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ MainPage = new MainPage();
+ }
+
+ protected override async void OnStart()
+ {
+ base.OnStart();
+ await InitSdk(
+ channelId: "0e2b609a-8dce-4695-a80f-966fbfa87a88",
+ userId: "maui-user-123",
+ properties: new Dictionary
+ {
+ ["platform"] = "maui",
+ ["plan"] = "free"
+ },
+ initOptions: new Screeb.Maui.ScreebInitOptions { IsDebugMode = true }
+ );
+ }
+}
diff --git a/examples/example-maui/MainPage.xaml b/examples/example-maui/MainPage.xaml
new file mode 100644
index 0000000..49d9be1
--- /dev/null
+++ b/examples/example-maui/MainPage.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/example-maui/MainPage.xaml.cs b/examples/example-maui/MainPage.xaml.cs
new file mode 100644
index 0000000..26c46db
--- /dev/null
+++ b/examples/example-maui/MainPage.xaml.cs
@@ -0,0 +1,115 @@
+using static Screeb.Maui.Screeb;
+
+namespace ExampleMaui;
+
+public partial class MainPage : ContentPage
+{
+ public MainPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnTrackEventClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await TrackEvent("button_clicked", new Dictionary { ["button"] = "track_event" });
+ StatusLabel.Text = "Event tracked ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnTrackScreenClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await TrackScreen("MainPage");
+ StatusLabel.Text = "Screen tracked ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnSetIdentityClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await SetIdentity("maui-user-123", new Dictionary { ["plan"] = "premium" });
+ StatusLabel.Text = "Identity set ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnResetIdentityClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await ResetIdentity();
+ StatusLabel.Text = "Identity reset ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnGetIdentityClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ var identity = await GetIdentity();
+ StatusLabel.Text = $"Identity: {identity?.Count ?? 0} props";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnAssignGroupClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await AssignGroup("company", "Screeb", new Dictionary { ["plan"] = "enterprise" });
+ StatusLabel.Text = "Group assigned ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnStartSurveyClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await StartSurvey("");
+ StatusLabel.Text = "Survey started ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async void OnStartMessageClicked(object sender, EventArgs e)
+ {
+ try
+ {
+ await StartMessage("");
+ StatusLabel.Text = "Message started ✓";
+ }
+ catch (Exception ex)
+ {
+ StatusLabel.Text = $"Error: {ex.Message}";
+ }
+ }
+}
diff --git a/examples/example-maui/MauiProgram.cs b/examples/example-maui/MauiProgram.cs
new file mode 100644
index 0000000..baeff7e
--- /dev/null
+++ b/examples/example-maui/MauiProgram.cs
@@ -0,0 +1,18 @@
+using Microsoft.Extensions.Logging;
+
+namespace ExampleMaui;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder.UseMauiApp();
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
diff --git a/examples/example-maui/Platforms/Android/MainActivity.cs b/examples/example-maui/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..5ca2ccd
--- /dev/null
+++ b/examples/example-maui/Platforms/Android/MainActivity.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Content.PM;
+
+namespace ExampleMaui;
+
+[Activity(
+ Theme = "@style/Maui.SplashTheme",
+ MainLauncher = true,
+ LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
+ ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
+ ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/examples/example-maui/Platforms/Android/MainApplication.cs b/examples/example-maui/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..00ceb19
--- /dev/null
+++ b/examples/example-maui/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace ExampleMaui;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/examples/example-maui/Platforms/iOS/Program.cs b/examples/example-maui/Platforms/iOS/Program.cs
new file mode 100644
index 0000000..a859462
--- /dev/null
+++ b/examples/example-maui/Platforms/iOS/Program.cs
@@ -0,0 +1,14 @@
+using UIKit;
+
+namespace ExampleMaui;
+
+public class Program
+{
+ static void Main(string[] args) =>
+ UIApplication.Main(args, null, typeof(AppDelegate));
+}
+
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/examples/example-maui/README.md b/examples/example-maui/README.md
new file mode 100644
index 0000000..ef0ad1c
--- /dev/null
+++ b/examples/example-maui/README.md
@@ -0,0 +1,20 @@
+# example-maui
+
+Example .NET MAUI app demonstrating the Screeb SDK.
+
+## Run
+
+```bash
+# Android (API 24+)
+dotnet build -t:Run -f net9.0-android
+
+# iOS (macOS only, iOS 14+)
+dotnet build -t:Run -f net9.0-ios
+```
+
+The channel ID is configured in `App.xaml.cs`.
+
+## Notes
+
+- Android requires `Platforms/Android/MainApplication.cs` (already included) to bootstrap the MAUI runtime.
+- iOS requires the `Screeb.xcframework` binary in `packages/sdk-maui/native/ios/` (see SDK README for setup).
diff --git a/examples/example-maui/example-maui.csproj b/examples/example-maui/example-maui.csproj
new file mode 100644
index 0000000..2387674
--- /dev/null
+++ b/examples/example-maui/example-maui.csproj
@@ -0,0 +1,36 @@
+
+
+ net9.0-android;net9.0-ios
+ Exe
+ ExampleMaui
+ app.screeb.example.maui
+ 1
+ 0.1.0
+ enable
+ enable
+ true
+ 24.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/sdk-maui/CHANGELOG.md b/packages/sdk-maui/CHANGELOG.md
new file mode 100644
index 0000000..26138ab
--- /dev/null
+++ b/packages/sdk-maui/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 0.1.0 (initial release)
+- Android + iOS support
+- 18 methods: InitSdk, SetIdentity, SetProperties, AssignGroup, UnassignGroup, TrackEvent, TrackScreen, StartSurvey, StartMessage, CloseSdk, CloseSurvey, CloseMessage, SessionReplayStart, SessionReplayStop, ResetIdentity, GetIdentity, Debug, DebugTargeting
diff --git a/packages/sdk-maui/Platforms/Android/.gitkeep b/packages/sdk-maui/Platforms/Android/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/sdk-maui/Platforms/Android/HooksAndroid.cs b/packages/sdk-maui/Platforms/Android/HooksAndroid.cs
new file mode 100644
index 0000000..ca25138
--- /dev/null
+++ b/packages/sdk-maui/Platforms/Android/HooksAndroid.cs
@@ -0,0 +1,81 @@
+// packages/sdk-maui/Platforms/Android/HooksAndroid.cs
+#if ANDROID
+namespace Screeb.Maui;
+
+internal static class HooksAndroid
+{
+ internal static IDictionary? ToGlobalHooks(Dictionary uuidMap)
+ => BuildHooksMap(uuidMap);
+
+ internal static IDictionary? ToSurveyHooks(Dictionary uuidMap)
+ => BuildHooksMap(uuidMap);
+
+ private static IDictionary? BuildHooksMap(Dictionary uuidMap)
+ {
+ if (uuidMap.Count == 0) return null;
+ var map = new Dictionary();
+ foreach (var (key, value) in uuidMap)
+ {
+ if (key == "version")
+ {
+ map[key] = new Java.Lang.String(value);
+ }
+ else
+ {
+ var uuid = value; // capture
+ map[key] = new KotlinHookCallback(payload =>
+ {
+ var fn = HooksRegistry.Get(uuid);
+ if (fn != null)
+ {
+ Task.Run(() => fn(payload?.ToString() ?? "{}"));
+ }
+ });
+ }
+ }
+ return map;
+ }
+}
+
+///
+/// Wraps a C# Action as a Kotlin Function1<Object, Unit> so the
+/// Android Screeb SDK can call it as a hook callback.
+///
+[Android.Runtime.Register("app/screeb/maui/KotlinHookCallback")]
+internal class KotlinHookCallback : Java.Lang.Object, Kotlin.Jvm.Functions.IFunction1
+{
+ private readonly Action _onHook;
+
+ public KotlinHookCallback(Action onHook)
+ {
+ _onHook = onHook;
+ }
+
+ public Java.Lang.Object? Invoke(Java.Lang.Object? p0)
+ {
+ _onHook(p0);
+ return null; // Kotlin Unit → null
+ }
+}
+
+///
+/// Wraps a C# Action as a Kotlin Function2<Object, Object, Unit> for
+/// result/error callbacks (GetIdentity, Debug, DebugTargeting).
+///
+[Android.Runtime.Register("app/screeb/maui/KotlinResultCallback")]
+internal sealed class KotlinResultCallback : Java.Lang.Object, Kotlin.Jvm.Functions.IFunction2
+{
+ private readonly Action _callback;
+
+ internal KotlinResultCallback(Action callback)
+ {
+ _callback = callback;
+ }
+
+ public Java.Lang.Object? Invoke(Java.Lang.Object? p1, Java.Lang.Object? p2)
+ {
+ _callback(p1, p2);
+ return null; // Kotlin Unit → null
+ }
+}
+#endif
diff --git a/packages/sdk-maui/Platforms/Android/Screeb.Android.cs b/packages/sdk-maui/Platforms/Android/Screeb.Android.cs
new file mode 100644
index 0000000..f6877b1
--- /dev/null
+++ b/packages/sdk-maui/Platforms/Android/Screeb.Android.cs
@@ -0,0 +1,185 @@
+// packages/sdk-maui/Platforms/Android/Screeb.Android.cs
+#if ANDROID
+using Android.OS;
+using App.Screeb.Sdk;
+
+namespace Screeb.Maui;
+
+public static partial class Screeb
+{
+ private static readonly Handler _mainHandler = new Handler(Looper.MainLooper!);
+
+ // Dispatches body to the main thread; catches exceptions and forwards them to the returned Task.
+ private static Task OnMain(Action> body)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _mainHandler.Post(() =>
+ {
+ try { body(tcs); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ private static Task OnMain(Action body) =>
+ OnMain(tcs => { body(); tcs.SetResult(true); });
+
+ private static Task OnMain(Action> body) where T : class
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _mainHandler.Post(() =>
+ {
+ try { body(tcs); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public static partial Task InitSdk(
+ string channelId, string? userId, Dictionary? properties,
+ ScreebHooks? hooks, ScreebInitOptions? initOptions, string? language)
+ => OnMain(() =>
+ {
+ var uuidMap = HooksRegistry.RegisterHooks(hooks);
+ App.Screeb.Sdk.Screeb.Instance.SetSecondarySDK("maui", SdkVersion);
+ App.Screeb.Sdk.Screeb.Instance.PluginInit(
+ channelId, userId,
+ ToJavaDictionary(ScreebUtils.FormatProperties(properties)),
+ ToInitOptionsMap(initOptions),
+ HooksAndroid.ToGlobalHooks(uuidMap),
+ language);
+ });
+
+ public static partial Task CloseSdk() => OnMain(() =>
+ {
+ HooksRegistry.UnregisterAll();
+ App.Screeb.Sdk.Screeb.Instance.CloseSdk();
+ });
+
+ public static partial Task SetIdentity(string userId, Dictionary? properties)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.SetIdentity(
+ userId, ToJavaDictionary(ScreebUtils.FormatProperties(properties))));
+
+ public static partial Task SetProperties(Dictionary? properties)
+ => OnMain(() =>
+ {
+ var props = ToJavaDictionary(ScreebUtils.FormatProperties(properties));
+ if (props != null) App.Screeb.Sdk.Screeb.Instance.SetVisitorProperties(props);
+ });
+
+ public static partial Task ResetIdentity()
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.ResetIdentity());
+
+ public static partial Task?> GetIdentity()
+ => OnMain>(tcs =>
+ App.Screeb.Sdk.Screeb.Instance.GetIdentity(new KotlinResultCallback((identity, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.ToString()));
+ else tcs.SetResult(FromJavaObject(identity));
+ })));
+
+ public static partial Task AssignGroup(string? groupType, string groupName, Dictionary? properties)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.AssignGroup(
+ groupType, groupName, ToJavaDictionary(ScreebUtils.FormatProperties(properties))));
+
+ public static partial Task UnassignGroup(string? groupType, string groupName, Dictionary? properties)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.UnassignGroup(
+ groupType, groupName, ToJavaDictionary(ScreebUtils.FormatProperties(properties))));
+
+ public static partial Task TrackEvent(string name, Dictionary? properties)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.TrackEvent(
+ name, ToJavaDictionary(ScreebUtils.FormatProperties(properties))));
+
+ public static partial Task TrackScreen(string name, Dictionary? properties)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.TrackScreen(
+ name, ToJavaDictionary(ScreebUtils.FormatProperties(properties))));
+
+ public static partial Task StartSurvey(
+ string surveyId, bool allowMultipleResponses, Dictionary? hiddenFields,
+ bool ignoreSurveyStatus, ScreebHooks? hooks, string? language, string? distributionId)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.StartSurvey(
+ surveyId, allowMultipleResponses,
+ ToJavaDictionary(ScreebUtils.FormatProperties(hiddenFields)),
+ ignoreSurveyStatus,
+ HooksAndroid.ToSurveyHooks(HooksRegistry.RegisterHooks(hooks)),
+ language, distributionId));
+
+ public static partial Task CloseSurvey(string? surveyId)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.CloseSurvey(surveyId));
+
+ public static partial Task StartMessage(
+ string messageId, bool allowMultipleResponses, Dictionary? hiddenFields,
+ bool ignoreMessageStatus, ScreebHooks? hooks, string? language, string? distributionId)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.StartMessage(
+ messageId, allowMultipleResponses,
+ ToJavaDictionary(ScreebUtils.FormatProperties(hiddenFields)),
+ ignoreMessageStatus,
+ HooksAndroid.ToSurveyHooks(HooksRegistry.RegisterHooks(hooks)),
+ language, distributionId));
+
+ public static partial Task CloseMessage(string? messageId)
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.CloseMessage(messageId));
+
+ public static partial Task SessionReplayStart()
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.SessionReplayStart());
+
+ public static partial Task SessionReplayStop()
+ => OnMain(() => App.Screeb.Sdk.Screeb.Instance.SessionReplayStop());
+
+ public static partial Task Debug()
+ => OnMain(tcs =>
+ App.Screeb.Sdk.Screeb.Instance.Debug(new KotlinResultCallback((info, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.ToString()));
+ else tcs.SetResult(info?.ToString());
+ })));
+
+ public static partial Task DebugTargeting()
+ => OnMain(tcs =>
+ App.Screeb.Sdk.Screeb.Instance.DebugTargeting(new KotlinResultCallback((info, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.ToString()));
+ else tcs.SetResult(info?.ToString());
+ })));
+
+ // --- Helpers ---
+
+ private static IDictionary? ToJavaDictionary(Dictionary? dict)
+ {
+ if (dict == null) return null;
+ var map = new Dictionary();
+ foreach (var (k, v) in dict)
+ map[k] = v is string s ? new Java.Lang.String(s) :
+ v is bool b ? Java.Lang.Boolean.ValueOf(b)! :
+ v is int i ? Java.Lang.Integer.ValueOf(i)! :
+ v is long l ? Java.Lang.Long.ValueOf(l)! :
+ v is double d ? Java.Lang.Double.ValueOf(d)! :
+ new Java.Lang.String(v.ToString() ?? "");
+ return map;
+ }
+
+ private static IDictionary? ToInitOptionsMap(ScreebInitOptions? opts)
+ {
+ if (opts == null) return null;
+ return new Dictionary
+ {
+ ["isDebugMode"] = Java.Lang.Boolean.ValueOf(opts.IsDebugMode)!,
+ ["disableMirror"] = Java.Lang.Boolean.ValueOf(opts.DisableMirror)!
+ };
+ }
+
+ private static Dictionary? FromJavaObject(Java.Lang.Object? obj)
+ {
+ if (obj == null) return null;
+ if (obj is IDictionary dict)
+ {
+ var result = new Dictionary();
+ foreach (var (k, v) in dict) result[k] = v?.ToString() ?? "";
+ return result;
+ }
+ return null;
+ }
+
+
+}
+#endif
diff --git a/packages/sdk-maui/Platforms/iOS/.gitkeep b/packages/sdk-maui/Platforms/iOS/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/sdk-maui/Platforms/iOS/ApiDefinitions.cs b/packages/sdk-maui/Platforms/iOS/ApiDefinitions.cs
new file mode 100644
index 0000000..7fcee24
--- /dev/null
+++ b/packages/sdk-maui/Platforms/iOS/ApiDefinitions.cs
@@ -0,0 +1,123 @@
+// packages/sdk-maui/Platforms/iOS/ApiDefinitions.cs
+using System;
+using Foundation;
+using UIKit;
+using ObjCRuntime;
+
+namespace Screeb.iOS.Binding;
+
+[Static]
+[DisableDefaultCtor]
+[BaseType(typeof(NSObject))]
+interface Screeb
+{
+ [Static]
+ [Export("setSecondarySDK:version:")]
+ void SetSecondarySDK(string name, string version);
+
+ [Static]
+ [Export("initSdk:channelId:identity:visitorProperty:initOptions:hooks:language:")]
+ void InitSdk(
+ [NullAllowed] UIViewController context,
+ string channelId,
+ [NullAllowed] string identity,
+ NSDictionary visitorProperty,
+ [NullAllowed] InitOptions initOptions,
+ [NullAllowed] NSDictionary hooks,
+ [NullAllowed] string language);
+
+ [Static]
+ [Export("closeSdk")]
+ void CloseSdk();
+
+ [Static]
+ [Export("setIdentity:visitorProperty:")]
+ void SetIdentity(string uniqueVisitorId, NSDictionary visitorProperty);
+
+ [Static]
+ [Export("visitorProperty:")]
+ void VisitorProperty(NSDictionary visitorProperty);
+
+ [Static]
+ [Export("resetIdentity")]
+ void ResetIdentity();
+
+ [Static]
+ [Export("getIdentity:")]
+ void GetIdentity(Action completion);
+
+ [Static]
+ [Export("assignGroup:name:properties:")]
+ void AssignGroup([NullAllowed] string type, string name, NSDictionary properties);
+
+ [Static]
+ [Export("unassignGroup:name:properties:")]
+ void UnassignGroup([NullAllowed] string type, string name, NSDictionary properties);
+
+ [Static]
+ [Export("trackEvent:trackingEventProperties:")]
+ void TrackEvent(string name, NSDictionary trackingEventProperties);
+
+ [Static]
+ [Export("trackScreen:trackingEventProperties:")]
+ void TrackScreen(string name, NSDictionary trackingEventProperties);
+
+ [Static]
+ [Export("startSurvey:allowMultipleResponses:hiddenFields:ignoreSurveyStatus:hooks:language:distributionId:")]
+ void StartSurvey(
+ string surveyId,
+ bool allowMultipleResponses,
+ NSDictionary hiddenFields,
+ bool ignoreSurveyStatus,
+ [NullAllowed] NSDictionary hooks,
+ [NullAllowed] string language,
+ [NullAllowed] string distributionId);
+
+ [Static]
+ [Export("closeSurvey:")]
+ void CloseSurvey([NullAllowed] string surveyId);
+
+ [Static]
+ [Export("startMessage:allowMultipleResponses:hiddenFields:ignoreMessageStatus:hooks:language:distributionId:")]
+ void StartMessage(
+ string messageId,
+ bool allowMultipleResponses,
+ NSDictionary hiddenFields,
+ bool ignoreMessageStatus,
+ [NullAllowed] NSDictionary hooks,
+ [NullAllowed] string language,
+ [NullAllowed] string distributionId);
+
+ [Static]
+ [Export("closeMessage:")]
+ void CloseMessage([NullAllowed] string messageId);
+
+ [Static]
+ [Export("sessionReplayStart")]
+ void SessionReplayStart();
+
+ [Static]
+ [Export("sessionReplayStop")]
+ void SessionReplayStop();
+
+ [Static]
+ [Export("debug:")]
+ void Debug(Action completion);
+
+ [Static]
+ [Export("debugTargeting:")]
+ void DebugTargeting(Action completion);
+}
+
+[BaseType(typeof(NSObject))]
+interface InitOptions
+{
+ [Export("initWithDict:")]
+ NativeHandle Constructor(NSDictionary dict);
+
+ [Export("isDebugMode")]
+ bool IsDebugMode { get; set; }
+
+ [Export("disableMirror")]
+ bool DisableMirror { get; set; }
+}
diff --git a/packages/sdk-maui/Platforms/iOS/HooksIOS.cs b/packages/sdk-maui/Platforms/iOS/HooksIOS.cs
new file mode 100644
index 0000000..2cf3494
--- /dev/null
+++ b/packages/sdk-maui/Platforms/iOS/HooksIOS.cs
@@ -0,0 +1,28 @@
+#if IOS
+using Foundation;
+
+namespace Screeb.Maui;
+
+internal static class HooksIOS
+{
+ ///
+ /// Converts hook UUIDs to NSDictionary for passing to native iOS SDK.
+ /// NOTE: ObjC block-based hook callbacks are not yet supported in v0.1.0.
+ /// The "version" key is passed as a string; other hook keys are registered
+ /// in HooksRegistry but callbacks will not fire from the native iOS side.
+ ///
+ internal static NSDictionary? ToNSDictionary(Dictionary uuidMap)
+ {
+ if (uuidMap.Count == 0) return null;
+ var keys = new List();
+ var values = new List();
+
+ foreach (var (key, value) in uuidMap)
+ {
+ keys.Add(new NSString(key));
+ values.Add(new NSString(value));
+ }
+ return NSDictionary.FromObjectsAndKeys(values.ToArray(), keys.ToArray());
+ }
+}
+#endif
diff --git a/packages/sdk-maui/Platforms/iOS/Screeb.iOS.cs b/packages/sdk-maui/Platforms/iOS/Screeb.iOS.cs
new file mode 100644
index 0000000..7407e6f
--- /dev/null
+++ b/packages/sdk-maui/Platforms/iOS/Screeb.iOS.cs
@@ -0,0 +1,166 @@
+#if IOS
+using Foundation;
+using Screeb.iOS.Binding;
+using NativeScreeb = global::Screeb.iOS.Binding.Screeb;
+
+namespace Screeb.Maui;
+
+public static partial class Screeb
+{
+ // Dispatches body to the main thread; catches exceptions and forwards them to the returned Task.
+ private static Task OnMain(Action> body)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ NSRunLoop.Main.BeginInvokeOnMainThread(() =>
+ {
+ try { body(tcs); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ private static Task OnMain(Action body) =>
+ OnMain(tcs => { body(); tcs.SetResult(true); });
+
+ private static Task OnMain(Action> body) where T : class
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ NSRunLoop.Main.BeginInvokeOnMainThread(() =>
+ {
+ try { body(tcs); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ });
+ return tcs.Task;
+ }
+
+ public static partial Task InitSdk(
+ string channelId, string? userId, Dictionary? properties,
+ ScreebHooks? hooks, ScreebInitOptions? initOptions, string? language)
+ => OnMain(() =>
+ {
+ NativeScreeb.SetSecondarySDK("maui", SdkVersion);
+ NativeScreeb.InitSdk(null, channelId, userId,
+ ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary(),
+ new InitOptions(NSDictionary.FromObjectsAndKeys(
+ new object[] { NSNumber.FromBoolean(initOptions?.IsDebugMode ?? false), NSNumber.FromBoolean(initOptions?.DisableMirror ?? false) },
+ new object[] { "isDebugMode", "disableMirror" })),
+ HooksIOS.ToNSDictionary(HooksRegistry.RegisterHooks(hooks)),
+ language);
+ });
+
+ public static partial Task CloseSdk()
+ => OnMain(() => { HooksRegistry.UnregisterAll(); NativeScreeb.CloseSdk(); });
+
+ public static partial Task SetIdentity(string userId, Dictionary? properties)
+ => OnMain(() => NativeScreeb.SetIdentity(
+ userId, ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task SetProperties(Dictionary? properties)
+ => OnMain(() => NativeScreeb.VisitorProperty(
+ ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task ResetIdentity()
+ => OnMain(() => NativeScreeb.ResetIdentity());
+
+ public static partial Task?> GetIdentity()
+ => OnMain>(tcs =>
+ NativeScreeb.GetIdentity((identity, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.LocalizedDescription));
+ else tcs.SetResult(FromNSDictionary(identity));
+ }));
+
+ public static partial Task AssignGroup(string? groupType, string groupName, Dictionary? properties)
+ => OnMain(() => NativeScreeb.AssignGroup(
+ groupType, groupName, ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task UnassignGroup(string? groupType, string groupName, Dictionary? properties)
+ => OnMain(() => NativeScreeb.UnassignGroup(
+ groupType, groupName, ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task TrackEvent(string name, Dictionary? properties)
+ => OnMain(() => NativeScreeb.TrackEvent(
+ name, ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task TrackScreen(string name, Dictionary? properties)
+ => OnMain(() => NativeScreeb.TrackScreen(
+ name, ToNSDictionary(ScreebUtils.FormatProperties(properties)) ?? new NSDictionary()));
+
+ public static partial Task StartSurvey(
+ string surveyId, bool allowMultipleResponses, Dictionary? hiddenFields,
+ bool ignoreSurveyStatus, ScreebHooks? hooks, string? language, string? distributionId)
+ => OnMain(() => NativeScreeb.StartSurvey(
+ surveyId, allowMultipleResponses,
+ ToNSDictionary(ScreebUtils.FormatProperties(hiddenFields)) ?? new NSDictionary(),
+ ignoreSurveyStatus,
+ HooksIOS.ToNSDictionary(HooksRegistry.RegisterHooks(hooks)),
+ language, distributionId));
+
+ public static partial Task CloseSurvey(string? surveyId)
+ => OnMain(() => NativeScreeb.CloseSurvey(surveyId));
+
+ public static partial Task StartMessage(
+ string messageId, bool allowMultipleResponses, Dictionary? hiddenFields,
+ bool ignoreMessageStatus, ScreebHooks? hooks, string? language, string? distributionId)
+ => OnMain(() => NativeScreeb.StartMessage(
+ messageId, allowMultipleResponses,
+ ToNSDictionary(ScreebUtils.FormatProperties(hiddenFields)) ?? new NSDictionary(),
+ ignoreMessageStatus,
+ HooksIOS.ToNSDictionary(HooksRegistry.RegisterHooks(hooks)),
+ language, distributionId));
+
+ public static partial Task CloseMessage(string? messageId)
+ => OnMain(() => NativeScreeb.CloseMessage(messageId));
+
+ public static partial Task SessionReplayStart()
+ => OnMain(() => NativeScreeb.SessionReplayStart());
+
+ public static partial Task SessionReplayStop()
+ => OnMain(() => NativeScreeb.SessionReplayStop());
+
+ public static partial Task Debug()
+ => OnMain(tcs =>
+ NativeScreeb.Debug((info, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.LocalizedDescription));
+ else tcs.SetResult(info);
+ }));
+
+ public static partial Task DebugTargeting()
+ => OnMain(tcs =>
+ NativeScreeb.DebugTargeting((info, error) =>
+ {
+ if (error != null) tcs.SetException(new Exception(error.LocalizedDescription));
+ else tcs.SetResult(info);
+ }));
+
+ // --- Helpers ---
+
+ private static NSDictionary? ToNSDictionary(Dictionary? dict)
+ {
+ if (dict == null) return null;
+ var keys = new List();
+ var values = new List();
+ foreach (var (k, v) in dict)
+ {
+ keys.Add(new NSString(k));
+ values.Add(v is string s ? new NSString(s) :
+ v is bool b ? NSNumber.FromBoolean(b) :
+ v is int i ? NSNumber.FromInt32(i) :
+ v is long l ? NSNumber.FromInt64(l) :
+ v is double d ? NSNumber.FromDouble(d) :
+ (NSObject)new NSString(v.ToString() ?? ""));
+ }
+ return NSDictionary.FromObjectsAndKeys(values.ToArray(), keys.ToArray());
+ }
+
+ private static Dictionary? FromNSDictionary(NSDictionary? dict)
+ {
+ if (dict == null) return null;
+ var result = new Dictionary();
+ foreach (var key in dict.Keys)
+ result[key.ToString() ?? ""] = dict[key]?.ToString() ?? "";
+ return result;
+ }
+}
+#endif
diff --git a/packages/sdk-maui/Platforms/iOS/ScreebClassPtr.cs b/packages/sdk-maui/Platforms/iOS/ScreebClassPtr.cs
new file mode 100644
index 0000000..0165514
--- /dev/null
+++ b/packages/sdk-maui/Platforms/iOS/ScreebClassPtr.cs
@@ -0,0 +1,13 @@
+// packages/sdk-maui/Platforms/iOS/ScreebClassPtr.cs
+// Supplies the missing class_ptr for the bgen-generated static partial class.
+// bgen emits `static partial class Screeb` that references class_ptr but does
+// not initialize it for [Static] interfaces — we complete the partial here.
+using ObjCRuntime;
+
+namespace Screeb.iOS.Binding
+{
+ public static unsafe partial class Screeb
+ {
+ static readonly NativeHandle class_ptr = Class.GetHandle("Screeb");
+ }
+}
diff --git a/packages/sdk-maui/Platforms/iOS/StructsAndEnums.cs b/packages/sdk-maui/Platforms/iOS/StructsAndEnums.cs
new file mode 100644
index 0000000..dcd345f
--- /dev/null
+++ b/packages/sdk-maui/Platforms/iOS/StructsAndEnums.cs
@@ -0,0 +1,3 @@
+// packages/sdk-maui/Platforms/iOS/StructsAndEnums.cs
+// iOS SDK enums and structs — empty for Screeb SDK v0.1.0
+// (Screeb iOS SDK uses no exported enums at the ObjC bridge level)
diff --git a/packages/sdk-maui/README.md b/packages/sdk-maui/README.md
new file mode 100644
index 0000000..b039f53
--- /dev/null
+++ b/packages/sdk-maui/README.md
@@ -0,0 +1,141 @@
+# Screeb.Maui
+
+.NET MAUI SDK for [Screeb](https://screeb.app) — Continuous Product Discovery.
+
+Supports **Android** (API 24+) and **iOS** (14.0+).
+
+## Installation
+
+```sh
+dotnet add package Screeb.Maui
+```
+
+## Android Setup
+
+Add a `MainApplication.cs` under `Platforms/Android/` — this is required to bootstrap the MAUI runtime:
+
+```csharp
+using Android.App;
+using Android.Runtime;
+
+namespace YourApp;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership) { }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+```
+
+In your `.csproj`, set Android minimum SDK to 24:
+
+```xml
+24.0
+```
+
+## iOS Setup
+
+Add a `AppDelegate.cs` under `Platforms/iOS/` if not already present:
+
+```csharp
+using Foundation;
+
+namespace YourApp;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+```
+
+The iOS SDK requires the `Screeb.xcframework` binary — see [Development Setup](#development-setup) below.
+
+## Usage
+
+Call `InitSdk` in `App.xaml.cs` `OnStart`:
+
+```csharp
+using static Screeb.Maui.Screeb;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ MainPage = new MainPage();
+ }
+
+ protected override async void OnStart()
+ {
+ base.OnStart();
+ await InitSdk(
+ channelId: "",
+ userId: "user-123",
+ properties: new Dictionary { ["plan"] = "premium" },
+ initOptions: new ScreebInitOptions { IsDebugMode = true }
+ );
+ }
+}
+```
+
+### ScreebInitOptions
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `IsDebugMode` | `bool` | `false` | Enable verbose SDK logging |
+| `DisableMirror` | `bool` | `false` | Disable mirror/session replay |
+
+### ScreebHooks
+
+React to survey lifecycle events:
+
+```csharp
+var hooks = new ScreebHooks
+{
+ Version = "1.0.0",
+ Callbacks = new Dictionary>>
+ {
+ ["onSurveyShowed"] = async payload => { Console.WriteLine(payload); return null; },
+ ["onSurveyCompleted"] = async payload => { Console.WriteLine(payload); return null; }
+ }
+};
+await InitSdk(channelId: "", hooks: hooks);
+```
+
+## API
+
+| Method | Description |
+|---|---|
+| `InitSdk(channelId, ...)` | Initialize the SDK |
+| `CloseSdk()` | Stop the SDK |
+| `SetIdentity(userId, properties)` | Identify the current user |
+| `SetProperties(properties)` | Update user properties |
+| `ResetIdentity()` | Reset user identity (e.g. on logout) |
+| `GetIdentity()` | Get current visitor identity |
+| `AssignGroup(type, name, properties)` | Add user to a group |
+| `UnassignGroup(type, name, properties)` | Remove user from a group |
+| `TrackEvent(name, properties)` | Track a custom event |
+| `TrackScreen(name, properties)` | Track a screen navigation |
+| `StartSurvey(surveyId, ...)` | Start a survey programmatically |
+| `CloseSurvey(surveyId)` | Close the current survey |
+| `StartMessage(messageId, ...)` | Start a message programmatically |
+| `CloseMessage(messageId)` | Close the current message |
+| `SessionReplayStart()` | Start session replay recording |
+| `SessionReplayStop()` | Stop session replay recording |
+| `Debug()` | Get SDK debug info |
+| `DebugTargeting()` | Get targeting debug info |
+
+## Development Setup
+
+### iOS XCFramework
+
+The iOS SDK is distributed as a binary XCFramework (gitignored). Before building for iOS:
+
+1. Download the latest release from https://github.com/ScreebApp/sdk-ios-public/releases
+2. Extract `Screeb.xcframework` to `packages/sdk-maui/native/ios/Screeb.xcframework/`
+
+See [full documentation](https://developers.screeb.app/sdk-maui/install).
diff --git a/packages/sdk-maui/Screeb.cs b/packages/sdk-maui/Screeb.cs
new file mode 100644
index 0000000..66477a4
--- /dev/null
+++ b/packages/sdk-maui/Screeb.cs
@@ -0,0 +1,159 @@
+using System.Reflection;
+
+namespace Screeb.Maui;
+
+///
+/// Screeb SDK for .NET MAUI. All methods are thread-safe and run on the main thread.
+/// Call InitSdk before any other method.
+///
+public static partial class Screeb
+{
+ internal static readonly string SdkVersion =
+ typeof(Screeb).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion
+ ?.Split('+')[0] // strip build metadata (e.g. "0.1.0+abc123" → "0.1.0")
+ ?? "0.0.0";
+
+ ///
+ /// Initialize the Screeb SDK. Must be called before any other method.
+ ///
+ /// Your Screeb channel ID from the Screeb dashboard.
+ /// Visitor identifier. Pass null for anonymous visitors.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// Optional lifecycle callbacks for survey/message events.
+ /// Optional SDK configuration options.
+ /// ISO 639-1 language code (e.g. "en", "fr"). Pass null to use device locale.
+ /// true if initialization succeeded, false if it failed or SDK already initialized, null if unsupported.
+ public static partial Task InitSdk(
+ string channelId,
+ string? userId = null,
+ Dictionary? properties = null,
+ ScreebHooks? hooks = null,
+ ScreebInitOptions? initOptions = null,
+ string? language = null);
+
+ /// Stop the SDK. Opposite of InitSdk.
+ /// true if closure succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task CloseSdk();
+
+ /// Identify the current user with optional properties.
+ /// Visitor identifier.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if identification succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task SetIdentity(
+ string userId,
+ Dictionary? properties = null);
+
+ /// Send visitor properties without changing the identity.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if properties update succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task SetProperties(
+ Dictionary? properties = null);
+
+ /// Reset user identity (e.g. on logout).
+ /// true if reset succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task ResetIdentity();
+
+ /// Get current visitor identity and properties.
+ /// Dictionary of current identity and properties, or null if identity is unavailable or SDK not initialized.
+ public static partial Task?> GetIdentity();
+
+ /// Assign the current user to a group.
+ /// Group type identifier, or null for the default group type.
+ /// Group name identifier.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if assignment succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task AssignGroup(
+ string? groupType,
+ string groupName,
+ Dictionary? properties = null);
+
+ /// Remove the current user from a group.
+ /// Group type identifier, or null for the default group type.
+ /// Group name identifier.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if removal succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task UnassignGroup(
+ string? groupType,
+ string groupName,
+ Dictionary? properties = null);
+
+ /// Track a custom event.
+ /// Event name.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if event tracking succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task TrackEvent(
+ string name,
+ Dictionary? properties = null);
+
+ /// Track a screen navigation event.
+ /// Event name.
+ /// User properties. Supports string, int, double, bool, DateTime, and nested Dictionary values. Pass null for no properties.
+ /// true if screen tracking succeeded, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task TrackScreen(
+ string name,
+ Dictionary? properties = null);
+
+ /// Start a specific survey programmatically.
+ /// Survey identifier.
+ /// Allow the same visitor to respond multiple times.
+ /// Pre-filled hidden field values.
+ /// Show survey even if it has already been completed.
+ /// Optional lifecycle callbacks for survey/message events.
+ /// ISO 639-1 language code (e.g. "en", "fr"). Pass null to use device locale.
+ /// Distribution identifier. Pass null to use the default distribution.
+ /// true if survey started successfully, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task StartSurvey(
+ string surveyId,
+ bool allowMultipleResponses = true,
+ Dictionary? hiddenFields = null,
+ bool ignoreSurveyStatus = true,
+ ScreebHooks? hooks = null,
+ string? language = null,
+ string? distributionId = null);
+
+ /// Close the currently displayed survey.
+ /// Survey identifier.
+ /// true if survey closed successfully, false if it failed or no survey is displayed, null if unsupported.
+ public static partial Task CloseSurvey(string? surveyId = null);
+
+ /// Start a specific message programmatically.
+ /// Message identifier.
+ /// Allow the same visitor to respond multiple times.
+ /// Pre-filled hidden field values.
+ /// Show message even if it has already been seen.
+ /// Optional lifecycle callbacks for survey/message events.
+ /// ISO 639-1 language code (e.g. "en", "fr"). Pass null to use device locale.
+ /// Distribution identifier. Pass null to use the default distribution.
+ /// true if message started successfully, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task StartMessage(
+ string messageId,
+ bool allowMultipleResponses = true,
+ Dictionary? hiddenFields = null,
+ bool ignoreMessageStatus = true,
+ ScreebHooks? hooks = null,
+ string? language = null,
+ string? distributionId = null);
+
+ /// Close the currently displayed message.
+ /// Message identifier.
+ /// true if message closed successfully, false if it failed or no message is displayed, null if unsupported.
+ public static partial Task CloseMessage(string? messageId = null);
+
+ /// Start session replay recording.
+ /// true if recording started successfully, false if it failed or SDK not initialized, null if unsupported.
+ public static partial Task SessionReplayStart();
+
+ /// Stop session replay recording.
+ /// true if recording stopped successfully, false if it failed or recording not active, null if unsupported.
+ public static partial Task SessionReplayStop();
+
+ /// Get SDK debug information.
+ /// Debug information string, or null if SDK is not initialized.
+ public static partial Task Debug();
+
+ /// Get targeting debug information (why surveys aren't showing).
+ /// Targeting debug information string, or null if SDK is not initialized.
+ public static partial Task DebugTargeting();
+}
diff --git a/packages/sdk-maui/ScreebHooks.cs b/packages/sdk-maui/ScreebHooks.cs
new file mode 100644
index 0000000..1add8b7
--- /dev/null
+++ b/packages/sdk-maui/ScreebHooks.cs
@@ -0,0 +1,54 @@
+namespace Screeb.Maui;
+
+public class ScreebHooks
+{
+ /// Hooks version identifier passed verbatim to the native SDK.
+ public required string Version { get; set; }
+
+ ///
+ /// Hook callbacks: key = hook name (e.g. "onSurveyShowed"),
+ /// value = async callback receiving the JSON payload string.
+ ///
+ public Dictionary>> Callbacks { get; set; } = new();
+}
+
+///
+/// Internal registry mapping UUID → C# callback. Used by platform implementations.
+/// Thread-safe via ConcurrentDictionary — callbacks are registered from the main thread
+/// but invoked from native Android/iOS callback threads.
+///
+internal static class HooksRegistry
+{
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary>> _registry = new();
+
+ internal static Dictionary RegisterHooks(ScreebHooks? hooks)
+ {
+ var uuidMap = new Dictionary();
+ if (hooks == null) return uuidMap;
+ if (hooks.Version != null)
+ uuidMap["version"] = hooks.Version;
+ foreach (var (key, callback) in hooks.Callbacks)
+ {
+ var uuid = Guid.NewGuid().ToString("N") + "_" + key;
+ _registry[uuid] = callback;
+ uuidMap[key] = uuid;
+ }
+ return uuidMap;
+ }
+
+ internal static Func>? Get(string uuid)
+ => _registry.TryGetValue(uuid, out var fn) ? fn : null;
+
+ /// Removes registered callbacks by UUID.
+ internal static void Unregister(IEnumerable uuids)
+ {
+ foreach (var uuid in uuids)
+ _registry.TryRemove(uuid, out _);
+ }
+
+ ///
+ /// Removes all registered callbacks. Call on CloseSdk to prevent unbounded
+ /// memory growth across repeated InitSdk/StartSurvey/StartMessage calls.
+ ///
+ internal static void UnregisterAll() => _registry.Clear();
+}
diff --git a/packages/sdk-maui/ScreebInitOptions.cs b/packages/sdk-maui/ScreebInitOptions.cs
new file mode 100644
index 0000000..fe08b2b
--- /dev/null
+++ b/packages/sdk-maui/ScreebInitOptions.cs
@@ -0,0 +1,7 @@
+namespace Screeb.Maui;
+
+public class ScreebInitOptions
+{
+ public bool IsDebugMode { get; set; } = false;
+ public bool DisableMirror { get; set; } = false;
+}
diff --git a/packages/sdk-maui/ScreebMaui.csproj b/packages/sdk-maui/ScreebMaui.csproj
new file mode 100644
index 0000000..d31a0c2
--- /dev/null
+++ b/packages/sdk-maui/ScreebMaui.csproj
@@ -0,0 +1,80 @@
+
+
+ net9.0-android;net9.0-ios
+ Screeb.Maui
+ Screeb.Maui
+ enable
+ enable
+ true
+
+ Screeb.Maui
+ 0.1.0
+ Screeb
+ Screeb
+ Screeb Continuous Product Discovery SDK for .NET MAUI (Android + iOS)
+ MIT
+ https://developers.screeb.app/sdk-maui/install
+ https://github.com/ScreebApp/sdk
+ README.md
+ maui android ios screeb survey analytics
+ git
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Framework
+ Foundation UIKit
+ false
+
+
+
+
+
+
+
+
+
diff --git a/packages/sdk-maui/ScreebUtils.cs b/packages/sdk-maui/ScreebUtils.cs
new file mode 100644
index 0000000..4382e85
--- /dev/null
+++ b/packages/sdk-maui/ScreebUtils.cs
@@ -0,0 +1,35 @@
+namespace Screeb.Maui;
+
+public static class ScreebUtils
+{
+ ///
+ /// Converts DateTime/DateTimeOffset values to ISO 8601 strings with timezone offset,
+ /// e.g. "2024-01-15T10:30:00.000+02:00". Recursively processes nested dictionaries.
+ /// Mirrors Flutter's _formatDates and React Native's normalizeValue.
+ ///
+ public static Dictionary? FormatProperties(Dictionary? props)
+ {
+ if (props == null) return null;
+ var result = new Dictionary(props.Count);
+ foreach (var (key, value) in props)
+ result[key] = FormatValue(value);
+ return result;
+ }
+
+ private static object FormatValue(object value) => value switch
+ {
+ DateTimeOffset dto => FormatDateTimeOffset(dto),
+ DateTime dt => FormatDateTimeOffset(new DateTimeOffset(dt)),
+ Dictionary nested => FormatProperties(nested)!,
+ _ => value
+ };
+
+ private static string FormatDateTimeOffset(DateTimeOffset dto)
+ {
+ var offset = dto.Offset;
+ var sign = offset >= TimeSpan.Zero ? "+" : "-";
+ var absHours = Math.Abs(offset.Hours).ToString("D2");
+ var mins = Math.Abs(offset.Minutes).ToString("D2");
+ return $"{dto:yyyy-MM-ddTHH:mm:ss.fff}{sign}{absHours}:{mins}";
+ }
+}
diff --git a/packages/sdk-maui/native/ios/.gitkeep b/packages/sdk-maui/native/ios/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packages/sdk-maui/tests/ScreebUtilsTests.cs b/packages/sdk-maui/tests/ScreebUtilsTests.cs
new file mode 100644
index 0000000..0cf30ef
--- /dev/null
+++ b/packages/sdk-maui/tests/ScreebUtilsTests.cs
@@ -0,0 +1,63 @@
+using Screeb.Maui;
+using Xunit;
+
+namespace Screeb.Maui.Tests;
+
+public class ScreebUtilsTests
+{
+ [Fact]
+ public void FormatProperties_NullInput_ReturnsNull()
+ {
+ var result = ScreebUtils.FormatProperties(null);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void FormatProperties_NoDateValues_ReturnsSameDictionary()
+ {
+ var input = new Dictionary { ["name"] = "Alice", ["age"] = 30 };
+ var result = ScreebUtils.FormatProperties(input)!;
+ Assert.Equal("Alice", result["name"]);
+ Assert.Equal(30, result["age"]);
+ }
+
+ [Fact]
+ public void FormatProperties_DateTimeValue_ConvertsToIso8601WithOffset()
+ {
+ var dt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(2));
+ var input = new Dictionary { ["created_at"] = dt };
+ var result = ScreebUtils.FormatProperties(input)!;
+ Assert.Equal("2024-01-15T10:30:00.000+02:00", result["created_at"]);
+ }
+
+ [Fact]
+ public void FormatProperties_NestedDictionary_RecursivelyFormats()
+ {
+ var dt = new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero);
+ var input = new Dictionary
+ {
+ ["meta"] = new Dictionary { ["at"] = dt }
+ };
+ var result = ScreebUtils.FormatProperties(input)!;
+ var nested = (Dictionary)result["meta"];
+ Assert.Equal("2024-06-01T00:00:00.000+00:00", nested["at"]);
+ }
+
+ [Fact]
+ public void FormatProperties_DateTimeUtcValue_ConvertsToIso8601WithZeroOffset()
+ {
+ var dt = DateTime.SpecifyKind(new DateTime(2024, 1, 15, 10, 30, 0), DateTimeKind.Utc);
+ var input = new Dictionary { ["ts"] = dt };
+ var result = ScreebUtils.FormatProperties(input)!;
+ Assert.Equal("2024-01-15T10:30:00.000+00:00", result["ts"]);
+ }
+
+ [Fact]
+ public void FormatProperties_NegativeOffsetDate_IncludesMinusSign()
+ {
+ var dt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(-5));
+ var input = new Dictionary { ["ts"] = dt };
+ var result = ScreebUtils.FormatProperties(input)!;
+ Assert.Equal("2024-01-15T10:30:00.000-05:00", result["ts"]);
+ }
+}
diff --git a/packages/sdk-maui/tests/ScreebUtilsTests.csproj b/packages/sdk-maui/tests/ScreebUtilsTests.csproj
new file mode 100644
index 0000000..2125b43
--- /dev/null
+++ b/packages/sdk-maui/tests/ScreebUtilsTests.csproj
@@ -0,0 +1,18 @@
+
+
+ net9.0
+ enable
+ enable
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers
+ all
+
+
+
+
+