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(); + + foreach (var operation in transaction.Crud) + { + batch.Add( + new + { + op = operation.Op.ToString(), + table = operation.Table, + id = operation.Id, + data = operation.OpData, + } + ); + } + + var payload = JsonSerializer.Serialize(new { batch }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient.PostAsync( + $"{BackendUrl}/api/data", + content + ); + + if (!response.IsSuccessStatusCode) + { + throw new Exception( + $"Received {response.StatusCode} from /api/data: {await response.Content.ReadAsStringAsync()}" + ); + } + + await transaction.Complete(); + } + catch (Exception ex) + { + Console.WriteLine($"UploadData Error: {ex.Message}"); + throw; + } + } +} diff --git a/demos/WPF/ViewModels/MainWindowViewModel.cs b/demos/WPF/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..7b596d4 --- /dev/null +++ b/demos/WPF/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using PowerSync.Common.Client; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + #region Fields + private readonly PowerSyncDatabase _db; + private bool _connected = false; + #endregion + + #region Properties + public bool Connected + { + get => _connected; + set + { + if (_connected != value) + { + _connected = value; + OnPropertyChanged(); + } + } + } + #endregion + + #region Constructor + public MainWindowViewModel(PowerSyncDatabase db) + { + _db = db; + // Set up the listener to track the status changes + _db.RunListener( + (update) => + { + if (update.StatusChanged != null) + { + Connected = update.StatusChanged.Connected; + } + } + ); + } + #endregion + } +} diff --git a/demos/WPF/ViewModels/RelayCommand.cs b/demos/WPF/ViewModels/RelayCommand.cs new file mode 100644 index 0000000..b336ea2 --- /dev/null +++ b/demos/WPF/ViewModels/RelayCommand.cs @@ -0,0 +1,83 @@ +using System; +using System.Windows.Input; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Func? _canExecute; + + /// + /// Initializes a new instance of the class. + /// + /// The action to execute when the command is invoked. + /// A function that determines whether the command can execute. + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + /// + /// Determines whether the command can execute. + /// + /// Optional parameter to determine execution state. + /// true if the command can execute; otherwise, false. + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + + /// + /// Executes the command. + /// + /// Optional parameter to pass to the execute logic. + public void Execute(object? parameter) => _execute(); + + /// + /// Occurs when changes in the state should be raised. + /// + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + } + + public class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Func? _canExecute; + + /// + /// Initializes a new instance of the class. + /// + /// The action to execute when the command is invoked. + /// A function that determines whether the command can execute. + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + /// + /// Determines whether the command can execute. + /// + /// The parameter used to determine execution state. + /// true if the command can execute; otherwise, false. + public bool CanExecute(object? parameter) => _canExecute?.Invoke((T)parameter!) ?? true; + + /// + /// Executes the command with the provided parameter. + /// + /// The parameter to pass to the execute logic. + public void Execute(object? parameter) => _execute((T)parameter!); + + /// + /// Occurs when changes in the state should be raised. + /// + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + } +} diff --git a/demos/WPF/ViewModels/SQLConsoleViewModel.cs b/demos/WPF/ViewModels/SQLConsoleViewModel.cs new file mode 100644 index 0000000..f655bbf --- /dev/null +++ b/demos/WPF/ViewModels/SQLConsoleViewModel.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows.Input; +using Newtonsoft.Json; +using PowerSync.Common.Client; +using PowersyncDotnetTodoList.Services; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public class SQLConsoleViewModel : ViewModelBase + { + #region Fields + private readonly PowerSyncDatabase _db; + private readonly INavigationService _navigationService; + #endregion + + #region Properties + private string _sqlQuery = "SELECT * FROM lists LIMIT 10;"; + public string SqlQuery + { + get => _sqlQuery; + set + { + if (_sqlQuery != value) + { + _sqlQuery = value; + OnPropertyChanged(); + } + } + } + + private ObservableCollection _queryResults = new(); + public ObservableCollection QueryResults + { + get => _queryResults; + set + { + _queryResults = value; + OnPropertyChanged(); + } + } + + private string _errorMessage = string.Empty; + public string ErrorMessage + { + get => _errorMessage; + set + { + _errorMessage = value; + OnPropertyChanged(); + } + } + #endregion + + #region Commands + public ICommand ExecuteQueryCommand { get; } + public ICommand BackCommand { get; } + #endregion + + #region Constructor + public SQLConsoleViewModel(IPowerSyncDatabase db, INavigationService navigationService) + { + _db = + db as PowerSyncDatabase + ?? throw new InvalidCastException("Expected PowerSyncDatabase instance."); + _navigationService = navigationService; + + ExecuteQueryCommand = new RelayCommand(async () => await ExecuteQuery()); + BackCommand = new RelayCommand(GoBack); + + _ = ExecuteQuery(); + } + #endregion + + #region Methods + private async Task ExecuteQuery() + { + if (string.IsNullOrWhiteSpace(SqlQuery)) + return; + + try + { + ErrorMessage = string.Empty; + + // Fetch results from database + var results = await _db.GetAll(SqlQuery, []); + + // Update the collection with the results + QueryResults = new ObservableCollection(results); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + QueryResults = []; + } + } + + private void GoBack() + { + _navigationService.GoBack(); + } + #endregion + } +} diff --git a/demos/WPF/ViewModels/TodoListViewModel.cs b/demos/WPF/ViewModels/TodoListViewModel.cs new file mode 100644 index 0000000..2104d96 --- /dev/null +++ b/demos/WPF/ViewModels/TodoListViewModel.cs @@ -0,0 +1,207 @@ +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows.Input; +using PowerSync.Common.Client; +using PowersyncDotnetTodoList.Models; +using PowersyncDotnetTodoList.Services; +using PowersyncDotnetTodoList.Views; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public class TodoListViewModel : ViewModelBase + { + #region Fields + private readonly PowerSyncDatabase _db; + private readonly PowerSyncConnector _connector; + private readonly INavigationService _navigationService; + #endregion + + #region Properties + public ObservableCollection TodoLists { get; } = []; + private TodoList? _selectedList; + + public TodoList? SelectedList + { + get => _selectedList; + set + { + if (_selectedList != value) + { + _selectedList = value; + OnPropertyChanged(); + OpenList(_selectedList); + } + } + } + + private string _newListName = ""; + public string NewListName + { + get => _newListName; + set + { + if (_newListName != value) + { + _newListName = value; + OnPropertyChanged(); + } + } + } + #endregion + + #region Commands + public ICommand AddListCommand { get; } + public ICommand DeleteListCommand { get; } + + public ICommand SQLConsoleCommand { get; } + #endregion + + #region Constructor + public TodoListViewModel( + PowerSyncDatabase db, + PowerSyncConnector connector, + INavigationService navigationService + ) + { + _db = db; + _connector = connector; + _navigationService = navigationService; + + AddListCommand = new RelayCommand( + async (newListName) => + { + if (!string.IsNullOrWhiteSpace(newListName)) + { + await AddList(newListName); + } + } + ); + + DeleteListCommand = new RelayCommand( + async (list) => + { + if (list != null) + { + await DeleteList(list); + } + } + ); + SQLConsoleCommand = new RelayCommand(GoToSQLConsole); + + WatchForChanges(); + LoadTodoLists(); + } + #endregion + + #region Methods + private async void LoadTodoLists() + { + var query = + @" + SELECT + l.*, + COUNT(t.id) AS total_tasks, + SUM(CASE WHEN t.completed = 1 THEN 1 ELSE 0 END) AS CompletedTasks, + SUM(CASE WHEN t.completed = 0 THEN 1 ELSE 0 END) AS PendingTasks, + MAX(t.completed_at) AS last_completed_at + FROM + lists l + LEFT JOIN todos t + ON l.id = t.list_id + GROUP BY + l.id + ORDER BY + last_completed_at DESC NULLS LAST; + "; + + var lists = await _db.GetAll(query); + TodoLists.Clear(); + if (lists != null && lists.Any()) + { + foreach (var list in lists) + { + TodoLists.Add(list); + } + } + } + + private async void WatchForChanges() + { + var query = + @" + SELECT + l.*, + COUNT(t.id) AS total_tasks, + SUM(CASE WHEN t.completed = 1 THEN 1 ELSE 0 END) AS CompletedTasks, + SUM(CASE WHEN t.completed = 0 THEN 1 ELSE 0 END) AS PendingTasks, + MAX(t.completed_at) AS last_completed_at + FROM + lists l + LEFT JOIN todos t + ON l.id = t.list_id + GROUP BY + l.id + ORDER BY + last_completed_at DESC NULLS LAST; + "; + + await _db.Watch( + query, + null, + new WatchHandler + { + OnResult = (results) => + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null) + { + dispatcher.Invoke(() => + { + TodoLists.Clear(); + foreach (var result in results) + { + TodoLists.Add(result); + } + }); + } + }, + OnError = (error) => + { + Console.WriteLine("Error: " + error.Message); + }, + } + ); + } + + private async Task AddList(string newListName) + { + await _db.Execute( + "INSERT INTO lists (id, name, owner_id, created_at) VALUES (uuid(), ?, ?, datetime());", + [newListName, _connector!.UserId] + ); + + NewListName = ""; + } + + private async Task DeleteList(TodoList list) + { + await _db.Execute("DELETE FROM lists WHERE id = ?;", [list.Id]); + TodoLists.Remove(list); + } + + private void OpenList(TodoList? selectedList) + { + if (selectedList != null) + { + _navigationService.Navigate(selectedList); + } + } + + private void GoToSQLConsole() + { + // Navigate back to the SQLConsole View + _navigationService.Navigate(); + } + #endregion + } +} diff --git a/demos/WPF/ViewModels/TodoViewModel.cs b/demos/WPF/ViewModels/TodoViewModel.cs new file mode 100644 index 0000000..726e32a --- /dev/null +++ b/demos/WPF/ViewModels/TodoViewModel.cs @@ -0,0 +1,185 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using PowerSync.Common.Client; +using PowersyncDotnetTodoList.Models; +using PowersyncDotnetTodoList.Services; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public class TodoViewModel : ViewModelBase + { + #region Fields + private readonly PowerSyncDatabase _db; + private readonly PowerSyncConnector _connector; + private readonly INavigationService _navigationService; + private TodoList? _list; + #endregion + + #region Properties + public ObservableCollection Todos { get; } = new(); + + private Todo? _selectedTodo; + public Todo? SelectedTodo + { + get => _selectedTodo; + set + { + _selectedTodo = value; + OnPropertyChanged(); + } + } + + private string _newTodoName = ""; + public string NewTodoName + { + get => _newTodoName; + set + { + if (_newTodoName != value) + { + _newTodoName = value; + OnPropertyChanged(); + } + } + } + + private string _listName = ""; + public string ListName + { + get => _listName; + set + { + _listName = value; + OnPropertyChanged(); + } + } + #endregion + + #region Commands + public ICommand AddTodoCommand { get; } + public ICommand DeleteTodoCommand { get; } + public ICommand ToggleCompleteCommand { get; } + public ICommand BackCommand { get; } + #endregion + + #region Constructor + public TodoViewModel( + IPowerSyncDatabase db, + PowerSyncConnector connector, + INavigationService navigationService + ) + { + _db = + db as PowerSyncDatabase + ?? throw new InvalidCastException("Expected PowerSyncDatabase instance."); + _connector = connector; + _navigationService = navigationService; + + AddTodoCommand = new RelayCommand( + async (newTodoName) => + { + if (!string.IsNullOrWhiteSpace(newTodoName)) + { + await AddTodo(newTodoName); + } + } + ); + DeleteTodoCommand = new RelayCommand(async (todo) => await DeleteTodo(todo)); + ToggleCompleteCommand = new RelayCommand( + async (todo) => await ToggleComplete(todo) + ); + BackCommand = new RelayCommand(GoBack); + } + #endregion + + #region Methods + public void SetList(TodoList list) + { + _list = list; + _listName = list.Name; + LoadTodos(); + WatchForChanges(); + } + + private async void WatchForChanges() + { + await _db.Watch( + "SELECT * FROM todos where list_id = ? ORDER BY created_at;", + [_list!.Id], + new WatchHandler + { + OnResult = (results) => + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null) + { + dispatcher.Invoke(() => + { + Todos.Clear(); + foreach (var result in results) + { + Todos.Add(result); + } + }); + } + }, + OnError = (error) => + { + Console.WriteLine("Error: " + error.Message); + }, + } + ); + } + + private async void LoadTodos() + { + var todos = await _db.GetAll( + "SELECT * FROM todos where list_id = ? ORDER BY created_at;", + [_list!.Id] + ); + Todos.Clear(); + foreach (var todo in todos) + { + Todos.Add(todo); + } + } + + private async Task AddTodo(string todoName) + { + if (!string.IsNullOrWhiteSpace(todoName)) + { + await _db.Execute( + "INSERT INTO todos (id, description, completed, created_at, list_id) VALUES (uuid(), ?, 0, datetime(), ?);", + [todoName, _list!.Id] + ); + LoadTodos(); + NewTodoName = ""; + } + } + + private async Task DeleteTodo(Todo todo) + { + await _db.Execute("DELETE FROM todos WHERE id = ?;", [todo.Id]); + Todos.Remove(todo); + } + + private async Task ToggleComplete(Todo todo) + { + // Toggle the completed state + var newCompletionState = todo.Completed ? 1 : 0; + + // Update the database with the new completion state + await _db.Execute( + "UPDATE todos SET completed = ? WHERE id = ?;", + [newCompletionState, todo.Id] + ); + } + + private void GoBack() + { + // Navigate back to the TodoList view + _navigationService.GoBack(); + } + #endregion + } +} diff --git a/demos/WPF/ViewModels/ViewModelBase.cs b/demos/WPF/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..5e819f9 --- /dev/null +++ b/demos/WPF/ViewModels/ViewModelBase.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace PowersyncDotnetTodoList.ViewModels +{ + public abstract class ViewModelBase : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/demos/WPF/Views/SQLConsoleView.xaml b/demos/WPF/Views/SQLConsoleView.xaml new file mode 100644 index 0000000..77df6c9 --- /dev/null +++ b/demos/WPF/Views/SQLConsoleView.xaml @@ -0,0 +1,124 @@ + + + + + + + + + +