diff --git a/README.md b/README.md
index 62b8233..c2ea905 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou
### Command-Line
- [demos/CommandLine](./demos/CommandLine/README.md): A CLI to-do list example app using a Node-js backend.
+- [demos/WPF](./demos/WPF/README.md): This is a demo WPF application that showcases how to use the [PowerSync SDK](https://www.powersync.com) for data synchronization in a to-do list application using a Node-js backend.
# Supported Frameworks
diff --git a/demos/CommandLine/.gitignore b/demos/CommandLine/.gitignore
index e335cef..945cff1 100644
--- a/demos/CommandLine/.gitignore
+++ b/demos/CommandLine/.gitignore
@@ -1 +1,2 @@
-user_id.txt
\ No newline at end of file
+user_id.txt
+.env
\ No newline at end of file
diff --git a/demos/WPF/.config/dotnet-tools.json b/demos/WPF/.config/dotnet-tools.json
new file mode 100644
index 0000000..393d187
--- /dev/null
+++ b/demos/WPF/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "csharpier": {
+ "version": "0.30.6",
+ "commands": [
+ "dotnet-csharpier"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/demos/WPF/.env.template b/demos/WPF/.env.template
new file mode 100644
index 0000000..421e259
--- /dev/null
+++ b/demos/WPF/.env.template
@@ -0,0 +1,2 @@
+BACKEND_URL=
+POWERSYNC_URL=
\ No newline at end of file
diff --git a/demos/WPF/.gitignore b/demos/WPF/.gitignore
new file mode 100644
index 0000000..c026799
--- /dev/null
+++ b/demos/WPF/.gitignore
@@ -0,0 +1,66 @@
+## A streamlined .gitignore for modern .NET projects
+## including temporary files, build results, and
+## files generated by popular .NET tools. If you are
+## developing with Visual Studio, the VS .gitignore
+## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+## has more thorough IDE-specific entries.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+
+# Others
+~$*
+*~
+CodeCoverage/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+*.txt
+
+*.db
+*.db-shm
+*.db-wal
+
+fe.pug
+fe/
+
+.env
+
diff --git a/demos/WPF/App.xaml b/demos/WPF/App.xaml
new file mode 100644
index 0000000..bc72abf
--- /dev/null
+++ b/demos/WPF/App.xaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/demos/WPF/App.xaml.cs b/demos/WPF/App.xaml.cs
new file mode 100644
index 0000000..0479cf1
--- /dev/null
+++ b/demos/WPF/App.xaml.cs
@@ -0,0 +1,90 @@
+using System.Diagnostics;
+using System.Windows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using PowerSync.Common.Client;
+using PowersyncDotnetTodoList.Models;
+using PowersyncDotnetTodoList.Services;
+using PowersyncDotnetTodoList.ViewModels;
+using PowersyncDotnetTodoList.Views;
+
+namespace PowersyncDotnetTodoList
+{
+ public partial class App : Application
+ {
+ public static IServiceProvider? Services { get; private set; }
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ var services = new ServiceCollection();
+ ConfigureServices(services);
+
+ // Build the service provider
+ Services = services.BuildServiceProvider();
+
+ // Initialize the database and connector
+ var db = Services.GetRequiredService();
+ var connector = Services.GetRequiredService();
+ await db.Init();
+ await db.Connect(connector);
+ await db.WaitForFirstSync();
+
+ var mainWindow = Services.GetRequiredService();
+
+ var navigationService = Services.GetRequiredService();
+ navigationService.Navigate();
+
+ mainWindow.Show();
+ }
+
+ private void ConfigureServices(IServiceCollection services)
+ {
+ ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Information);
+ });
+
+ // Register PowerSyncDatabase
+ services.AddSingleton(sp =>
+ {
+ var logger = loggerFactory.CreateLogger("PowerSyncLogger");
+ return new PowerSyncDatabase(
+ new PowerSyncDatabaseOptions
+ {
+ Database = new SQLOpenOptions { DbFilename = "example.db" },
+ Schema = AppSchema.PowerSyncSchema,
+ Logger = logger,
+ }
+ );
+ });
+
+ // Register IPowerSyncDatabase explicitly
+ services.AddSingleton(sp =>
+ sp.GetRequiredService()
+ );
+
+ // Register PowerSyncConnector
+ services.AddSingleton();
+
+ // Register ViewModels and Views
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton(sp =>
+ {
+ var mainWindow = sp.GetRequiredService();
+ return new NavigationService(mainWindow.MainFrame, sp);
+ });
+ }
+ }
+}
diff --git a/demos/WPF/AssemblyInfo.cs b/demos/WPF/AssemblyInfo.cs
new file mode 100644
index 0000000..cc29e7f
--- /dev/null
+++ b/demos/WPF/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly:ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/demos/WPF/Assets/Icons/icon-192x192.png b/demos/WPF/Assets/Icons/icon-192x192.png
new file mode 100644
index 0000000..66a7234
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon-192x192.png differ
diff --git a/demos/WPF/Assets/Icons/icon-256x256.png b/demos/WPF/Assets/Icons/icon-256x256.png
new file mode 100644
index 0000000..1b8b97b
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon-256x256.png differ
diff --git a/demos/WPF/Assets/Icons/icon-384x384.png b/demos/WPF/Assets/Icons/icon-384x384.png
new file mode 100644
index 0000000..af8be4d
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon-384x384.png differ
diff --git a/demos/WPF/Assets/Icons/icon-512x512.png b/demos/WPF/Assets/Icons/icon-512x512.png
new file mode 100644
index 0000000..eb291c7
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon-512x512.png differ
diff --git a/demos/WPF/Assets/Icons/icon.ico b/demos/WPF/Assets/Icons/icon.ico
new file mode 100644
index 0000000..15fe3e4
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon.ico differ
diff --git a/demos/WPF/Assets/Icons/icon.png b/demos/WPF/Assets/Icons/icon.png
new file mode 100644
index 0000000..c254b17
Binary files /dev/null and b/demos/WPF/Assets/Icons/icon.png differ
diff --git a/demos/WPF/Converters/BoolStatusConverter.cs b/demos/WPF/Converters/BoolStatusConverter.cs
new file mode 100644
index 0000000..18f7884
--- /dev/null
+++ b/demos/WPF/Converters/BoolStatusConverter.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+using System.Windows.Data;
+
+namespace PowersyncDotnetTodoList.Converters
+{
+ public class BoolToStatusConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is bool connected)
+ {
+ return connected ? "Connected" : "Disconnected";
+ }
+ return "Disconnected"; // Default if the value isn't a boolean
+ }
+
+ public object ConvertBack(
+ object value,
+ Type targetType,
+ object parameter,
+ CultureInfo culture
+ )
+ {
+ return value is string str && str == "Connected";
+ }
+ }
+}
diff --git a/demos/WPF/Converters/StringToVisibilityConverter.cs b/demos/WPF/Converters/StringToVisibilityConverter.cs
new file mode 100644
index 0000000..759bbfd
--- /dev/null
+++ b/demos/WPF/Converters/StringToVisibilityConverter.cs
@@ -0,0 +1,26 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace PowersyncDotnetTodoList.Converters
+{
+ public class StringToVisibilityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return string.IsNullOrEmpty(value as string)
+ ? Visibility.Collapsed
+ : Visibility.Visible;
+ }
+
+ public object ConvertBack(
+ object value,
+ Type targetType,
+ object parameter,
+ CultureInfo culture
+ )
+ {
+ return Visibility.Visible;
+ }
+ }
+}
diff --git a/demos/WPF/LICENSE b/demos/WPF/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/demos/WPF/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/demos/WPF/MainWindow.xaml b/demos/WPF/MainWindow.xaml
new file mode 100644
index 0000000..b50e4b7
--- /dev/null
+++ b/demos/WPF/MainWindow.xaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demos/WPF/MainWindow.xaml.cs b/demos/WPF/MainWindow.xaml.cs
new file mode 100644
index 0000000..26047c2
--- /dev/null
+++ b/demos/WPF/MainWindow.xaml.cs
@@ -0,0 +1,14 @@
+using System.Windows;
+using PowersyncDotnetTodoList.ViewModels;
+
+namespace PowersyncDotnetTodoList;
+
+public partial class MainWindow : Window
+{
+ public MainWindow(MainWindowViewModel viewModel)
+ {
+ InitializeComponent();
+
+ this.DataContext = viewModel;
+ }
+}
diff --git a/demos/WPF/Models/AppSchema.cs b/demos/WPF/Models/AppSchema.cs
new file mode 100644
index 0000000..646697f
--- /dev/null
+++ b/demos/WPF/Models/AppSchema.cs
@@ -0,0 +1,42 @@
+using PowerSync.Common.DB.Schema;
+
+namespace PowersyncDotnetTodoList.Models;
+
+class AppSchema
+{
+ public static Table Todos = new Table(
+ new Dictionary
+ {
+ { "list_id", ColumnType.TEXT },
+ { "created_at", ColumnType.TEXT },
+ { "completed_at", ColumnType.TEXT },
+ { "description", ColumnType.TEXT },
+ { "created_by", ColumnType.TEXT },
+ { "completed_by", ColumnType.TEXT },
+ { "completed", ColumnType.INTEGER },
+ },
+ new TableOptions
+ {
+ Indexes = new Dictionary>
+ {
+ {
+ "list",
+ new List { "list_id" }
+ },
+ },
+ }
+ );
+
+ public static Table Lists = new Table(
+ new Dictionary
+ {
+ { "created_at", ColumnType.TEXT },
+ { "name", ColumnType.TEXT },
+ { "owner_id", ColumnType.TEXT },
+ }
+ );
+
+ public static Schema PowerSyncSchema = new Schema(
+ new Dictionary { { "todos", Todos }, { "lists", Lists } }
+ );
+}
diff --git a/demos/WPF/Models/Todo.cs b/demos/WPF/Models/Todo.cs
new file mode 100644
index 0000000..5398e4b
--- /dev/null
+++ b/demos/WPF/Models/Todo.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace PowersyncDotnetTodoList.Models;
+
+public class Todo
+{
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = string.Empty;
+
+ [JsonPropertyName("list_id")]
+ public string ListId { get; init; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = string.Empty;
+
+ [JsonPropertyName("created_at")]
+ public string CreatedAt { get; init; } = string.Empty;
+
+ [JsonPropertyName("created_by")]
+ public string CreatedBy { get; init; } = string.Empty;
+
+ [JsonPropertyName("completed")]
+ public bool Completed { get; set; } = false;
+
+ [JsonPropertyName("completed_at")]
+ public string CompletedAt { get; set; } = string.Empty;
+
+ [JsonPropertyName("completed_by")]
+ public string CompletedBy { get; set; } = string.Empty;
+}
diff --git a/demos/WPF/Models/TodoList.cs b/demos/WPF/Models/TodoList.cs
new file mode 100644
index 0000000..fcbcb06
--- /dev/null
+++ b/demos/WPF/Models/TodoList.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace PowersyncDotnetTodoList.Models;
+
+public class TodoList
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("owner_id")]
+ public string OwnerId { get; set; } = string.Empty;
+
+ [JsonPropertyName("created_at")]
+ public string CreatedAt { get; set; } = string.Empty;
+}
diff --git a/demos/WPF/Models/TodoListWithStats.cs b/demos/WPF/Models/TodoListWithStats.cs
new file mode 100644
index 0000000..d40f264
--- /dev/null
+++ b/demos/WPF/Models/TodoListWithStats.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace PowersyncDotnetTodoList.Models;
+
+public class TodoListWithStats : TodoList
+{
+ [JsonPropertyName("pending_tasks")]
+ public int PendingTasks { get; set; } = 0;
+
+ [JsonPropertyName("completed_tasks")]
+ public int CompletedTasks { get; set; } = 0;
+}
diff --git a/demos/WPF/README.md b/demos/WPF/README.md
new file mode 100644
index 0000000..3b63b1b
--- /dev/null
+++ b/demos/WPF/README.md
@@ -0,0 +1,57 @@
+# PowerSync + WPF Demo: To-Do List
+
+## Overview
+
+This is a demo WPF application that showcases how to use the [PowerSync SDK](https://www.powersync.com) for data synchronization in a to-do list application. The app leverages PowerSync to sync task lists and items while working seamlessly online and offline.
+
+To run this demo, you need to have one of our Node.js self-host demos ([Postgres](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs) | [MongoDB](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs-mongodb) | [MySQL](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs-mysql)) running, as it provides the PowerSync server that this CLI's PowerSync SDK connects to.
+
+Changes made to the backend's source DB or to the self-hosted web UI will be synced to this CLI client (and vice versa).
+
+## Getting Started
+
+### Prerequisites
+
+Ensure you have the following installed:
+- [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
+
+### Configuration
+
+Copy the example environment file and update it with your PowerSync credentials:
+
+```sh
+cp .env.template .env
+```
+
+Edit `.env` to include the necessary API keys and connection details.
+
+### Running the App
+
+Build and run the WPF application using the .NET CLI:
+
+```sh
+dotnet build
+```
+
+Run the app:
+
+```sh
+dotnet run
+```
+
+Alternatively, open the solution in Visual Studio and start debugging (`F5`).
+
+## Features
+
+- **Offline-first Sync**: PowerSync ensures that tasks and lists are synchronized efficiently when online.
+- **Task Management**: Add, edit, complete, and delete tasks within lists.
+- **MVVM Architecture**: Clean separation of concerns with ViewModels and Views.
+- **Shell Navigation**: `MainWindow` serves as the main navigation shell.
+
+## Learn More
+
+- [PowerSync SDK Documentation](https://docs.powersync.com)
+- [PowerSync GitHub Repository](https://github.com/powersync-ja/powersync-js)
+
+Feedback and contributions are welcome!
+
diff --git a/demos/WPF/Services/INavigationService.cs b/demos/WPF/Services/INavigationService.cs
new file mode 100644
index 0000000..b009d00
--- /dev/null
+++ b/demos/WPF/Services/INavigationService.cs
@@ -0,0 +1,131 @@
+using System.Windows.Controls;
+using Microsoft.Extensions.DependencyInjection;
+using PowersyncDotnetTodoList.Models;
+using PowersyncDotnetTodoList.ViewModels;
+using PowersyncDotnetTodoList.Views;
+
+namespace PowersyncDotnetTodoList.Services
+{
+ public interface INavigationService
+ {
+ void Navigate()
+ where T : class;
+ void Navigate(object parameter)
+ where T : class;
+ void GoBack();
+ }
+}
+
+namespace PowersyncDotnetTodoList.Services
+{
+ public class NavigationService : INavigationService
+ {
+ private readonly Frame _frame;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly Dictionary _viewModelToViewMappings = [];
+
+ public NavigationService(Frame frame, IServiceProvider serviceProvider)
+ {
+ _frame = frame ?? throw new ArgumentNullException(nameof(frame));
+ _serviceProvider =
+ serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+
+ // Register your view-viewmodel mappings here
+ RegisterMapping();
+ RegisterMapping();
+ RegisterMapping();
+ RegisterMapping();
+ }
+
+ public void RegisterMapping()
+ where TViewModel : class
+ where TView : class
+ {
+ _viewModelToViewMappings[typeof(TViewModel)] = typeof(TView);
+ }
+
+ public void Navigate()
+ where T : class
+ {
+ Navigate(null);
+ }
+
+ public void Navigate(object? parameter)
+ where T : class
+ {
+ var viewModelType = typeof(T);
+
+ try
+ {
+ if (!_viewModelToViewMappings.TryGetValue(viewModelType, out var viewType))
+ {
+ throw new InvalidOperationException(
+ $"No view mapping found for ViewModel {viewModelType.FullName}"
+ );
+ }
+
+ var viewModel = _serviceProvider.GetRequiredService();
+
+ // If the view model is of type TodoViewModel, set the parameter
+ if (viewModel is TodoViewModel todoViewModel && parameter is TodoList list)
+ {
+ // Pass the selected TodoList to the TodoViewModel
+ todoViewModel.SetList(list);
+ }
+
+ var view = _serviceProvider.GetRequiredService(viewType);
+
+ if (view == null)
+ {
+ throw new InvalidOperationException(
+ $"Could not resolve view {viewType.FullName}"
+ );
+ }
+
+ // Handle both Page and UserControl
+ if (view is Page page)
+ {
+ page.DataContext = viewModel;
+ _frame.Content = page;
+ }
+ else if (view is UserControl userControl)
+ {
+ userControl.DataContext = viewModel;
+ _frame.Content = userControl;
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ $"View {viewType.FullName} must be either a Page or UserControl"
+ );
+ }
+
+ // If the ViewModel implements INavigationAware, call OnNavigatedTo
+ if (viewModel is INavigationAware navigationAware)
+ {
+ navigationAware.OnNavigatedTo(parameter!);
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Navigation error for {viewModelType.FullName}: {ex.Message}",
+ ex
+ );
+ }
+ }
+
+ public void GoBack()
+ {
+ if (_frame.CanGoBack)
+ {
+ _frame.GoBack();
+ }
+ }
+ }
+
+ public interface INavigationAware
+ {
+ void OnNavigatedTo(object parameter);
+ }
+}
diff --git a/demos/WPF/Services/PowerSyncConnector.cs b/demos/WPF/Services/PowerSyncConnector.cs
new file mode 100644
index 0000000..8ca5a07
--- /dev/null
+++ b/demos/WPF/Services/PowerSyncConnector.cs
@@ -0,0 +1,142 @@
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using DotNetEnv;
+using PowerSync.Common.Client;
+using PowerSync.Common.Client.Connection;
+using PowerSync.Common.DB.Crud;
+
+namespace PowersyncDotnetTodoList.Services;
+
+public class PowerSyncConnector : IPowerSyncBackendConnector
+{
+ private static readonly string StorageFilePath = "user_id.txt"; // Simulating local storage
+ private readonly HttpClient _httpClient;
+
+ public string BackendUrl { get; }
+ public string PowerSyncUrl { get; }
+ public string UserId { get; private set; }
+ private string? clientId;
+
+ public PowerSyncConnector()
+ {
+ Env.Load();
+
+ _httpClient = new HttpClient();
+
+ // Load or generate User ID
+ UserId = LoadOrGenerateUserId();
+
+ // Get URLs from environment variables
+ BackendUrl =
+ Environment.GetEnvironmentVariable("BACKEND_URL")
+ ?? throw new Exception(
+ "BACKEND_URL environment variable is not set. Please check your .env file."
+ );
+ PowerSyncUrl =
+ Environment.GetEnvironmentVariable("POWERSYNC_URL")
+ ?? throw new Exception(
+ "POWERSYNC_URL environment variable is not set. Please check your .env file."
+ );
+
+ clientId = null;
+ }
+
+ public string LoadOrGenerateUserId()
+ {
+ if (File.Exists(StorageFilePath))
+ {
+ return File.ReadAllText(StorageFilePath);
+ }
+
+ string newUserId = Guid.NewGuid().ToString();
+ File.WriteAllText(StorageFilePath, newUserId);
+ return newUserId;
+ }
+
+ public async Task FetchCredentials()
+ {
+ string tokenEndpoint = "api/auth/token";
+ string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}";
+
+ HttpResponseMessage response = await _httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new Exception(
+ $"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}"
+ );
+ }
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ var jsonResponse = JsonSerializer.Deserialize>(responseBody);
+
+ if (jsonResponse == null || !jsonResponse.ContainsKey("token"))
+ {
+ throw new Exception("Invalid response received from authentication endpoint.");
+ }
+
+ return new PowerSyncCredentials(PowerSyncUrl, jsonResponse["token"]);
+ }
+
+ public async Task UploadData(IPowerSyncDatabase database)
+ {
+ CrudTransaction? transaction;
+ try
+ {
+ transaction = await database.GetNextCrudTransaction();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"UploadData Error: {ex.Message}");
+ return;
+ }
+
+ if (transaction == null)
+ {
+ return;
+ }
+
+ clientId ??= await database.GetClientId();
+
+ try
+ {
+ var batch = new List