diff --git a/docs/content/samples/todoapp/maui.md b/docs/content/samples/todoapp/maui.md new file mode 100644 index 00000000..daa045cc --- /dev/null +++ b/docs/content/samples/todoapp/maui.md @@ -0,0 +1,72 @@ ++++ +title = "MAUI" ++++ + +## Run the application first + +The MAUI sample uses an in-memory Sqlite store for storing its data. To run the application locally: + +* [Configure Visual Studio for MAUI development](https://learn.microsoft.com/dotnet/maui/get-started/installation). +* Open `samples/todoapp/Samples.TodoApp.sln` in Visual Studio. +* In the Solution Explorer, right-click the `TodoApp.MAUI` project, then select **Set as Startup Project**. +* Select a target (in the top bar), then press F5 to run the application. + +The application runs on Android, iOS, and Windows. Each platform needs slightly different setup. Read the MAUI documentation for more information. + +## Deploy a datasync server to Azure + +Before you begin adjusting the application for offline usage, you must [deploy a datasync service](../server.md). Make a note of the URI of the service before continuing. + +## Update the application for datasync operations + +All the changes are isolated to the `Database/AppDbContext.cs` file. + +1. Change the definition of the class so that it inherits from `OfflineDbContext`: + + ```csharp + public class AppDbContext(DbContextOptions options) : OfflineDbContext(options) + { + // Rest of the class + } + ``` + +2. Add the `OnDatasyncInitialization()` method: + + ```csharp + protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) + { + HttpClientOptions clientOptions = new() + { + Endpoint = new Uri("https://YOURSITEHERE.azurewebsites.net/"), + HttpPipeline = [new LoggingHandler()] + }; + _ = optionsBuilder.UseHttpClientOptions(clientOptions); + } + ``` + + Replace the Endpoint with the URI of your datasync service. + +3. Update the `SynchronizeAsync()` method. + + The `SynchronizeAsync()` method is used by the application to synchronize data to and from the datasync service. It is called primarily from the `MainViewModel` which drives the UI interactions for the main list. + + ```csharp + public async Task SynchronizeAsync(CancellationToken cancellationToken = default) + { + PushResult pushResult = await this.PushAsync(cancellationToken); + if (!pushResult.IsSuccessful) + { + throw new ApplicationException($"Push failed: {pushResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + } + + PullResult pullResult = await this.PullAsync(cancellationToken); + if (!pullResult.IsSuccessful) + { + throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + } + } + ``` + +You can now re-run your application. Watch the console logs to show the interactions with the datasync service. Press the refresh button to synchronize data with the cloud. When you restart the application, your changes will automatically populate the database again. + +Obviously, you will want to do much more in a "real world" application, including proper error handling, authentication, and using a Sqlite file instead of an in-memory database. This example shows off the minimum required to add datasync services to an application. diff --git a/docs/content/samples/todoapp/winui3.md b/docs/content/samples/todoapp/winui3.md index 5945510f..a5933e8e 100644 --- a/docs/content/samples/todoapp/winui3.md +++ b/docs/content/samples/todoapp/winui3.md @@ -2,44 +2,71 @@ title = "WinUI3" +++ -You can find [our sample TodoApp for WinUI3](https://github.com/CommunityToolkit/Datasync/tree/main/samples/todoapp/TodoApp.WinUI3) on our GitHub repository. All of our logic has been placed in the `Database/AppDbContext.cs` file: - -{{< highlight lineNos="true" type="csharp" wrap="true" title="AppDbContext.cs" >}} -public class AppDbContext(DbContextOptions options) : OfflineDbContext(options) -{ - public DbSet TodoItems => Set(); - - protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) - { - HttpClientOptions clientOptions = new() - { - Endpoint = new Uri("https://YOURSITEHERE.azurewebsites.net/"), - HttpPipeline = [new LoggingHandler()] - }; - _ = optionsBuilder.UseHttpClientOptions(clientOptions); - } +## Run the application first - public async Task SynchronizeAsync(CancellationToken cancellationToken = default) - { - PushResult pushResult = await this.PushAsync(cancellationToken); - if (!pushResult.IsSuccessful) - { - throw new ApplicationException($"Push failed: {pushResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); - } - - PullResult pullResult = await this.PullAsync(cancellationToken); - if (!pullResult.IsSuccessful) - { - throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); - } - } -} -{{< /highlight >}} +The WinUI3 sample uses an in-memory Sqlite store for storing its data. To run the application locally: + +* [Configure Visual Studio for WinUI3 development](https://learn.microsoft.com/windows/apps/get-started/start-here). +* Open `samples/todoapp/Samples.TodoApp.sln` in Visual Studio. +* In the Solution Explorer, right-click the `TodoApp.WinUI3` project, then select **Set as Startup Project**. +* Select a target (in the top bar), then press F5 to run the application. + +If you bump into issues at this point, ensure you can properly develop and run WinUI3 applications outside of the datasync service. + +## Deploy a datasync server to Azure + +Before you begin adjusting the application for offline usage, you must [deploy a datasync service](../server.md). Make a note of the URI of the service before continuing. + +## Update the application for datasync operations + +All the changes are isolated to the `Database/AppDbContext.cs` file. + +1. Change the definition of the class so that it inherits from `OfflineDbContext`: -To enable offline synchronization: + ```csharp + public class AppDbContext(DbContextOptions options) : OfflineDbContext(options) + { + // Rest of the class + } + ``` + +2. Add the `OnDatasyncInitialization()` method: + + ```csharp + protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) + { + HttpClientOptions clientOptions = new() + { + Endpoint = new Uri("https://YOURSITEHERE.azurewebsites.net/"), + HttpPipeline = [new LoggingHandler()] + }; + _ = optionsBuilder.UseHttpClientOptions(clientOptions); + } + ``` + + Replace the Endpoint with the URI of your datasync service. + +3. Update the `SynchronizeAsync()` method. + + The `SynchronizeAsync()` method is used by the application to synchronize data to and from the datasync service. It is called primarily from the `MainViewModel` which drives the UI interactions for the main list. + + ```csharp + public async Task SynchronizeAsync(CancellationToken cancellationToken = default) + { + PushResult pushResult = await this.PushAsync(cancellationToken); + if (!pushResult.IsSuccessful) + { + throw new ApplicationException($"Push failed: {pushResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + } + + PullResult pullResult = await this.PullAsync(cancellationToken); + if (!pullResult.IsSuccessful) + { + throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + } + } + ``` -* Switch from `DbContext` to `OfflineDbContext`. -* Define your `OnDatasyncInitialization()` method (don't forget to change the URL to the URL of your datasync server). -* Where appropriate, use `PushAsync()` and `PullAsync()` to communicate with the server. +You can now re-run your application. Watch the console logs to show the interactions with the datasync service. Press the refresh button to synchronize data with the cloud. When you restart the application, your changes will automatically populate the database again. -We have placed a `SynchronizeAsync()` method on the database context, which is used in the view model for the single page we have. +Obviously, you will want to do much more in a "real world" application, including proper error handling, authentication, and using a Sqlite file instead of an in-memory database. This example shows off the minimum required to add datasync services to an application. diff --git a/docs/public/404.html b/docs/public/404.html index 2bd59099..8f8eea61 100644 --- a/docs/public/404.html +++ b/docs/public/404.html @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/css/chroma-auto.css b/docs/public/css/chroma-auto.css index bd1ea423..c2945061 100644 --- a/docs/public/css/chroma-auto.css +++ b/docs/public/css/chroma-auto.css @@ -1,2 +1,2 @@ -@import "chroma-relearn-light.css?1724869793" screen and (prefers-color-scheme: light); -@import "chroma-relearn-dark.css?1724869793" screen and (prefers-color-scheme: dark); +@import "chroma-relearn-light.css?1725059400" screen and (prefers-color-scheme: light); +@import "chroma-relearn-dark.css?1725059400" screen and (prefers-color-scheme: dark); diff --git a/docs/public/css/format-print.css b/docs/public/css/format-print.css index 69881690..f8c539de 100644 --- a/docs/public/css/format-print.css +++ b/docs/public/css/format-print.css @@ -1,5 +1,5 @@ -@import "theme-relearn-light.css?1724869793"; -@import "chroma-relearn-light.css?1724869793"; +@import "theme-relearn-light.css?1725059400"; +@import "chroma-relearn-light.css?1725059400"; #R-sidebar { display: none; diff --git a/docs/public/css/print.css b/docs/public/css/print.css index 4b50f26a..8bca50bc 100644 --- a/docs/public/css/print.css +++ b/docs/public/css/print.css @@ -1 +1 @@ -@import "format-print.css?1724869793"; +@import "format-print.css?1725059400"; diff --git a/docs/public/css/swagger.css b/docs/public/css/swagger.css index 9cdc3eb8..14169e77 100644 --- a/docs/public/css/swagger.css +++ b/docs/public/css/swagger.css @@ -1,7 +1,7 @@ /* Styles to make Swagger-UI fit into our theme */ -@import "fonts.css?1724869793"; -@import "variables.css?1724869793"; +@import "fonts.css?1725059400"; +@import "variables.css?1725059400"; body{ line-height: 1.574; diff --git a/docs/public/css/theme-auto.css b/docs/public/css/theme-auto.css index e7b44e9b..9e78840b 100644 --- a/docs/public/css/theme-auto.css +++ b/docs/public/css/theme-auto.css @@ -1,2 +1,2 @@ -@import "theme-relearn-light.css?1724869793" screen and (prefers-color-scheme: light); -@import "theme-relearn-dark.css?1724869793" screen and (prefers-color-scheme: dark); +@import "theme-relearn-light.css?1725059400" screen and (prefers-color-scheme: light); +@import "theme-relearn-dark.css?1725059400" screen and (prefers-color-scheme: dark); diff --git a/docs/public/css/variant.css b/docs/public/css/variant.css index 2d309e38..a5e85c51 100644 --- a/docs/public/css/variant.css +++ b/docs/public/css/variant.css @@ -1,4 +1,4 @@ -@import "variables.css?1724869793"; +@import "variables.css?1725059400"; html { color-scheme: only var(--INTERNAL-BROWSER-theme); diff --git a/docs/public/in-depth/client/index.html b/docs/public/in-depth/client/index.html index fc3a69b2..d2d19758 100644 --- a/docs/public/in-depth/client/index.html +++ b/docs/public/in-depth/client/index.html @@ -36,17 +36,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/client/oneline-operations/index.html b/docs/public/in-depth/client/oneline-operations/index.html index cdffd709..6fe19909 100644 --- a/docs/public/in-depth/client/oneline-operations/index.html +++ b/docs/public/in-depth/client/oneline-operations/index.html @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/index.html b/docs/public/in-depth/index.html index 6275cf98..8fa2dad6 100644 --- a/docs/public/in-depth/index.html +++ b/docs/public/in-depth/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/azuresql/index.html b/docs/public/in-depth/server/databases/azuresql/index.html index 97905a7a..00e2cab4 100644 --- a/docs/public/in-depth/server/databases/azuresql/index.html +++ b/docs/public/in-depth/server/databases/azuresql/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/cosmos/index.html b/docs/public/in-depth/server/databases/cosmos/index.html index b1e7ec0c..668a1215 100644 --- a/docs/public/in-depth/server/databases/cosmos/index.html +++ b/docs/public/in-depth/server/databases/cosmos/index.html @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/in-memory/index.html b/docs/public/in-depth/server/databases/in-memory/index.html index c79fbc14..4cb42394 100644 --- a/docs/public/in-depth/server/databases/in-memory/index.html +++ b/docs/public/in-depth/server/databases/in-memory/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/index.html b/docs/public/in-depth/server/databases/index.html index 88593c43..b4852356 100644 --- a/docs/public/in-depth/server/databases/index.html +++ b/docs/public/in-depth/server/databases/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/index.xml b/docs/public/in-depth/server/databases/index.xml index 54179e76..d941f78f 100644 --- a/docs/public/in-depth/server/databases/index.xml +++ b/docs/public/in-depth/server/databases/index.xml @@ -47,7 +47,7 @@ http://localhost:1313/Datasync/in-depth/server/databases/sqlite/index.html Mon, 01 Jan 0001 00:00:00 +0000 http://localhost:1313/Datasync/in-depth/server/databases/sqlite/index.html - SqLite Warning Do not use SqLite for production services. SqLite is only suitable for client-side usage in production. SqLite doesn’t have a date/time field that supports millisecond accuracy. As such, it isn’t suitable for anything except for testing. If you wish to use SqLite, ensure you implement a value converter and value comparer on each model for date/time properties. The easiest method to implement value converters and comparers is in the OnModelCreating(ModelBuilder) method of your DbContext: + SqLite Warning Do not use SqLite for production services. SqLite is only suitable for client-side usage in production. SqLite doesn’t have a date/time field that supports millisecond accuracy. As such, it isn’t suitable for anything except for testing. If you wish to use SqLite, ensure you implement a value converter and value comparer on each model for date/time properties. The easiest method to implement value converters and comparers is in the OnModelCreating(ModelBuilder) method of your DbContext: \ No newline at end of file diff --git a/docs/public/in-depth/server/databases/litedb/index.html b/docs/public/in-depth/server/databases/litedb/index.html index 5e546c46..09a9009f 100644 --- a/docs/public/in-depth/server/databases/litedb/index.html +++ b/docs/public/in-depth/server/databases/litedb/index.html @@ -40,17 +40,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/postgresql/index.html b/docs/public/in-depth/server/databases/postgresql/index.html index 2cfe1933..b0dd6d01 100644 --- a/docs/public/in-depth/server/databases/postgresql/index.html +++ b/docs/public/in-depth/server/databases/postgresql/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/sqlite/index.html b/docs/public/in-depth/server/databases/sqlite/index.html index 847dd6b1..cc03b514 100644 --- a/docs/public/in-depth/server/databases/sqlite/index.html +++ b/docs/public/in-depth/server/databases/sqlite/index.html @@ -1,124 +1,124 @@ - - - - - - - - - - - - - - - - - - - - - - - Sqlite :: Datasync Community Toolkit - - - - - - - - - - - - - - - - - -
-
- -
-
-
-
-
-
- -

Sqlite

- +
+
+ + + + + +
+
+
+
+
+
+ +

Sqlite

+

SqLite

- -
-
Warning
-
- + +
+
Warning
+
+

Do not use SqLite for production services. SqLite is only suitable for client-side usage in production.

-
+

SqLite doesn’t have a date/time field that supports millisecond accuracy. As such, it isn’t suitable for anything except for testing. If you wish to use SqLite, ensure you implement a value converter and value comparer on each model for date/time properties. The easiest method to implement value converters and comparers is in the OnModelCreating(ModelBuilder) method of your DbContext:

protected override void OnModelCreating(ModelBuilder builder)
 {
@@ -162,165 +162,166 @@ 

SqLite

InstallUpdateTriggers(context); } context.Database.SaveChanges(); -}
-
-
-
-
-
- - - - - - - +} +
+
+ + + + + + + + + + diff --git a/docs/public/in-depth/server/index.html b/docs/public/in-depth/server/index.html index 13ae131b..6e601f0c 100644 --- a/docs/public/in-depth/server/index.html +++ b/docs/public/in-depth/server/index.html @@ -32,17 +32,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/index.html b/docs/public/index.html index 96085fc2..2107aae6 100644 --- a/docs/public/index.html +++ b/docs/public/index.html @@ -24,17 +24,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/index.html b/docs/public/samples/index.html index 71eb8e23..4e74904f 100644 --- a/docs/public/samples/index.html +++ b/docs/public/samples/index.html @@ -36,17 +36,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/server/index.html b/docs/public/samples/server/index.html index b7f84bb1..fb0978e0 100644 --- a/docs/public/samples/server/index.html +++ b/docs/public/samples/server/index.html @@ -36,17 +36,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/index.html b/docs/public/samples/todoapp/index.html index 9aa49973..88a359e2 100644 --- a/docs/public/samples/todoapp/index.html +++ b/docs/public/samples/todoapp/index.html @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/index.xml b/docs/public/samples/todoapp/index.xml index 5fbbfa4b..a6a2fea4 100644 --- a/docs/public/samples/todoapp/index.xml +++ b/docs/public/samples/todoapp/index.xml @@ -7,19 +7,26 @@ Hugo en-us + + MAUI + http://localhost:1313/Datasync/samples/todoapp/maui/index.html + Mon, 01 Jan 0001 00:00:00 +0000 + http://localhost:1313/Datasync/samples/todoapp/maui/index.html + Run the application first The MAUI sample uses an in-memory Sqlite store for storing its data. To run the application locally: Configure Visual Studio for MAUI development. Open samples/todoapp/Samples.TodoApp.sln in Visual Studio. In the Solution Explorer, right-click the TodoApp.MAUI project, then select Set as Startup Project. Select a target (in the top bar), then press F5 to run the application. The application runs on Android, iOS, and Windows. Each platform needs slightly different setup. + WinUI3 http://localhost:1313/Datasync/samples/todoapp/winui3/index.html Mon, 01 Jan 0001 00:00:00 +0000 http://localhost:1313/Datasync/samples/todoapp/winui3/index.html - You can find our sample TodoApp for WinUI3 on our GitHub repository. All of our logic has been placed in the Database/AppDbContext.cs file: ​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE. + Run the application first The WinUI3 sample uses an in-memory Sqlite store for storing its data. To run the application locally: Configure Visual Studio for WinUI3 development. Open samples/todoapp/Samples.TodoApp.sln in Visual Studio. In the Solution Explorer, right-click the TodoApp.WinUI3 project, then select Set as Startup Project. Select a target (in the top bar), then press F5 to run the application. If you bump into issues at this point, ensure you can properly develop and run WinUI3 applications outside of the datasync service. WPF http://localhost:1313/Datasync/samples/todoapp/wpf/index.html Mon, 01 Jan 0001 00:00:00 +0000 http://localhost:1313/Datasync/samples/todoapp/wpf/index.html - You can find our sample TodoApp for WPF on our GitHub repository. All of our logic has been placed in the Database/AppDbContext.cs file: ​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE. + You can find our sample TodoApp for WPF on our GitHub repository. All of our logic has been placed in the Database/AppDbContext.cs file: ​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE. \ No newline at end of file diff --git a/docs/public/samples/todoapp/maui/index.html b/docs/public/samples/todoapp/maui/index.html new file mode 100644 index 00000000..db6dc479 --- /dev/null +++ b/docs/public/samples/todoapp/maui/index.html @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + MAUI :: Datasync Community Toolkit + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+ +

MAUI

+ +

Run the application first

+

The MAUI sample uses an in-memory Sqlite store for storing its data. To run the application locally:

+
    +
  • Configure Visual Studio for MAUI development.
  • +
  • Open samples/todoapp/Samples.TodoApp.sln in Visual Studio.
  • +
  • In the Solution Explorer, right-click the TodoApp.MAUI project, then select Set as Startup Project.
  • +
  • Select a target (in the top bar), then press F5 to run the application.
  • +
+

The application runs on Android, iOS, and Windows. Each platform needs slightly different setup. Read the MAUI documentation for more information.

+

Deploy a datasync server to Azure

+

Before you begin adjusting the application for offline usage, you must deploy a datasync service. Make a note of the URI of the service before continuing.

+

Update the application for datasync operations

+

All the changes are isolated to the Database/AppDbContext.cs file.

+
    +
  1. +

    Change the definition of the class so that it inherits from OfflineDbContext:

    +
    public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options)
    +{
    +  // Rest of the class
    +}
  2. +
  3. +

    Add the OnDatasyncInitialization() method:

    +
    protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
    +{
    +    HttpClientOptions clientOptions = new()
    +    {
    +        Endpoint = new Uri("https://YOURSITEHERE.azurewebsites.net/"),
    +        HttpPipeline = [new LoggingHandler()]
    +    };
    +    _ = optionsBuilder.UseHttpClientOptions(clientOptions);
    +}

    Replace the Endpoint with the URI of your datasync service.

    +
  4. +
  5. +

    Update the SynchronizeAsync() method.

    +

    The SynchronizeAsync() method is used by the application to synchronize data to and from the datasync service. It is called primarily from the MainViewModel which drives the UI interactions for the main list.

    +
    public async Task SynchronizeAsync(CancellationToken cancellationToken = default)
    +{
    +   PushResult pushResult = await this.PushAsync(cancellationToken);
    +   if (!pushResult.IsSuccessful)
    +   {
    +     throw new ApplicationException($"Push failed: {pushResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}");
    +   }
    +
    +   PullResult pullResult = await this.PullAsync(cancellationToken);
    +   if (!pullResult.IsSuccessful)
    +   {
    +     throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}");
    +   }
    + }
  6. +
+

You can now re-run your application. Watch the console logs to show the interactions with the datasync service. Press the refresh button to synchronize data with the cloud. When you restart the application, your changes will automatically populate the database again.

+

Obviously, you will want to do much more in a “real world” application, including proper error handling, authentication, and using a Sqlite file instead of an in-memory database. This example shows off the minimum required to add datasync services to an application.

+ +
+
+
+
+
+
+ + + + + + diff --git a/docs/public/samples/todoapp/winui3/index.html b/docs/public/samples/todoapp/winui3/index.html index 28595956..69e627f5 100644 --- a/docs/public/samples/todoapp/winui3/index.html +++ b/docs/public/samples/todoapp/winui3/index.html @@ -5,40 +5,40 @@ - + - + - + - - + + WinUI3 :: Datasync Community Toolkit - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/wpf/index.html b/docs/public/samples/todoapp/wpf/index.html index f203e55a..970096cd 100644 --- a/docs/public/samples/todoapp/wpf/index.html +++ b/docs/public/samples/todoapp/wpf/index.html @@ -1,130 +1,130 @@ - - - - - - - + + + + + + + - - - +​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE."> + + + - - - +​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE."> + + + - - - - +​ AppDbContext.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class AppDbContext(DbContextOptions<AppDbContext> options) : OfflineDbContext(options) { public DbSet<TodoItem> TodoItems => Set<TodoItem>(); protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) { HttpClientOptions clientOptions = new() { Endpoint = new Uri("https://YOURSITEHERE."> + + + + - - WPF :: Datasync Community Toolkit - - - - - - - - - - - - - - - - - -
-
- +
+
+
+
+
+
+ +

WPF

+

You can find our sample TodoApp for WPF on our GitHub repository. All of our logic has been placed in the Database/AppDbContext.cs file:

- -
-
-
- -
-
-
-
- + +
+
+
+ +
+
+
+
+
 1
@@ -188,9 +188,9 @@ 

WPF

} }
-
-
-
+
+
+

To enable offline synchronization:

    @@ -199,165 +199,166 @@

    WPF

  • Where appropriate, use PushAsync() and PullAsync() to communicate with the server.

We have placed a SynchronizeAsync() method on the database context, which is used in the view model for the single page we have.

- -
-
-
-
-
-
- - - - - - + +
+
+ + + + + + + + + + diff --git a/docs/public/setup/client/index.html b/docs/public/setup/client/index.html index 12246952..b18e35d6 100644 --- a/docs/public/setup/client/index.html +++ b/docs/public/setup/client/index.html @@ -1,79 +1,79 @@ - - - - - - - + + + + + + + - - - +Note Sqlite stores DateTimeOffset using a second accuracy by default."> + + + - - - +Note Sqlite stores DateTimeOffset using a second accuracy by default."> + + + - - - - +Note Sqlite stores DateTimeOffset using a second accuracy by default."> + + + + - - Client application :: Datasync Community Toolkit - - - - - - - - - - - - - - - - - -
-
- -
-
-
-
-
-
- -

Client application

- +
+
+ + + + + +
+
+
+
+
+
+ +

Client application

+

Prerequisites

For offline database access, you should create your application using Entity Framework Core v8.0 and a Sqlite database. When you construct your models for database storage, ensure they are constructed with the following requirements:

    @@ -126,13 +126,13 @@

    Prerequisites

  • Deleted - a bool field.

These are maintained by the server.

- -
-
Note
-
- + +
+
Note
+
+

Sqlite stores DateTimeOffset using a second accuracy by default. The Datasync Community Toolkit does not rely on the storage of the UpdatedAt field, but it is transmitted with millisecond accuracy. Consider using a ValueConverter to store the value as a long value instead.

-
+

Setup

Add the CommunityToolkit.Datasync.Client NuGet package to your application.

Change your DbContext to an OfflineDbContext

@@ -155,165 +155,166 @@

Create an OnDatasyncIni { throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); }

You should always push changes before pulling updated records from the remote service.

- -
-
-
-
-
- - - - - - - + +
+
+ + + + + + + + + + diff --git a/docs/public/setup/index.html b/docs/public/setup/index.html index c76d85b7..b0e20f32 100644 --- a/docs/public/setup/index.html +++ b/docs/public/setup/index.html @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/setup/index.xml b/docs/public/setup/index.xml index 654576fc..45c16c40 100644 --- a/docs/public/setup/index.xml +++ b/docs/public/setup/index.xml @@ -19,7 +19,7 @@ http://localhost:1313/Datasync/setup/client/index.html Mon, 01 Jan 0001 00:00:00 +0000 http://localhost:1313/Datasync/setup/client/index.html - Prerequisites For offline database access, you should create your application using Entity Framework Core v8.0 and a Sqlite database. When you construct your models for database storage, ensure they are constructed with the following requirements: Primary key - Id, a string field. UpdatedAt - a DateTimeOffset? field. Version - a string? or byte[]? field. Deleted - a bool field. These are maintained by the server. Note Sqlite stores DateTimeOffset using a second accuracy by default. + Prerequisites For offline database access, you should create your application using Entity Framework Core v8.0 and a Sqlite database. When you construct your models for database storage, ensure they are constructed with the following requirements: Primary key - Id, a string field. UpdatedAt - a DateTimeOffset? field. Version - a string? or byte[]? field. Deleted - a bool field. These are maintained by the server. Note Sqlite stores DateTimeOffset using a second accuracy by default. \ No newline at end of file diff --git a/docs/public/setup/server/index.html b/docs/public/setup/server/index.html index b43fc6c7..0013d41e 100644 --- a/docs/public/setup/server/index.html +++ b/docs/public/setup/server/index.html @@ -36,17 +36,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index d45ebf81..197feb00 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -45,6 +45,9 @@ http://localhost:1313/Datasync/in-depth/server/databases/litedb/index.html + + http://localhost:1313/Datasync/samples/todoapp/maui/index.html + http://localhost:1313/Datasync/in-depth/server/databases/postgresql/index.html diff --git a/docs/public/tags/index.html b/docs/public/tags/index.html index 044ad76b..e20a44f2 100644 --- a/docs/public/tags/index.html +++ b/docs/public/tags/index.html @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/samples/todoapp/Samples.TodoApp.sln b/samples/todoapp/Samples.TodoApp.sln index 5692d55c..69781c3a 100644 --- a/samples/todoapp/Samples.TodoApp.sln +++ b/samples/todoapp/Samples.TodoApp.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TodoApp.WPF", "TodoApp.WPF\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.Datasync.Server", "..\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{E67734DD-B397-4A65-AA50-D62F37EF05DD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.MAUI", "TodoApp.MAUI\TodoApp.MAUI.csproj", "{00430043-04C5-4F8F-87A9-98ECC0051808}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +99,30 @@ Global {E67734DD-B397-4A65-AA50-D62F37EF05DD}.Release|x64.Build.0 = Release|Any CPU {E67734DD-B397-4A65-AA50-D62F37EF05DD}.Release|x86.ActiveCfg = Release|Any CPU {E67734DD-B397-4A65-AA50-D62F37EF05DD}.Release|x86.Build.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|ARM64.Build.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|ARM64.Deploy.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x64.ActiveCfg = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x64.Build.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x64.Deploy.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x86.ActiveCfg = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x86.Build.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Debug|x86.Deploy.0 = Debug|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|Any CPU.Build.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|Any CPU.Deploy.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|ARM64.ActiveCfg = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|ARM64.Build.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|ARM64.Deploy.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x64.ActiveCfg = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x64.Build.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x64.Deploy.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x86.ActiveCfg = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x86.Build.0 = Release|Any CPU + {00430043-04C5-4F8F-87A9-98ECC0051808}.Release|x86.Deploy.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/todoapp/TodoApp.MAUI/App.xaml b/samples/todoapp/TodoApp.MAUI/App.xaml new file mode 100644 index 00000000..a8e18342 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/samples/todoapp/TodoApp.MAUI/App.xaml.cs b/samples/todoapp/TodoApp.MAUI/App.xaml.cs new file mode 100644 index 00000000..6172d556 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/App.xaml.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if WINDOWS +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Windows.Graphics; +#endif + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TodoApp.MAUI.Models; +using TodoApp.MAUI.ViewModels; +using TodoApp.MAUI.Services; + +namespace TodoApp.MAUI; + +public partial class App : Application, IDisposable +{ +#if WINDOWS + const int WindowWidth = 400; + const int WindowHeight = 800; +#endif + + private readonly SqliteConnection dbConnection; + + public IServiceProvider Services { get; } + + public App() + { + InitializeComponent(); + + this.dbConnection = new SqliteConnection("Data Source=:memory:"); + this.dbConnection.Open(); + + Services = new ServiceCollection() + .AddTransient() + .AddTransient() + .AddScoped() + .AddDbContext(options => options.UseSqlite(this.dbConnection)) + .BuildServiceProvider(); + + InitializeDatabase(); + + Microsoft.Maui.Handlers.WindowHandler.Mapper.AppendToMapping(nameof(IWindow), (handler, view) => + { +#if WINDOWS + handler.PlatformView.Activate(); + + IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(handler.PlatformView); + AppWindow appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(windowHandle)); + appWindow.Resize(new SizeInt32(WindowWidth, WindowHeight)); +#endif + }); + MainPage = new NavigationPage(new MainPage()); + } + + private void InitializeDatabase() + { + using IServiceScope scope = Services.CreateScope(); + IDbInitializer initializer = scope.ServiceProvider.GetRequiredService(); + initializer.Initialize(); + } + + /// + /// A helper method for getting a service from the services collection. + /// + /// The type of the service. + /// An instance of the service + public static TService GetRequiredService() where TService : notnull + => ((App)App.Current!).Services.GetRequiredService(); + + #region IDisposable + private bool hasDisposed; + + protected virtual void Dispose(bool disposing) + { + if (!this.hasDisposed) + { + if (disposing) + { + this.dbConnection.Close(); + } + + this.hasDisposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion +} diff --git a/samples/todoapp/TodoApp.MAUI/MainPage.xaml b/samples/todoapp/TodoApp.MAUI/MainPage.xaml new file mode 100644 index 00000000..1ba382a7 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/MainPage.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs b/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs new file mode 100644 index 00000000..e6a84b10 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using TodoApp.MAUI.Models; +using TodoApp.MAUI.ViewModels; + +namespace TodoApp.MAUI; + +public partial class MainPage : ContentPage +{ + private readonly MainViewModel _viewModel; + + public MainPage() + { + InitializeComponent(); + this._viewModel = App.GetRequiredService(); + BindingContext = this._viewModel; + } + + protected override void OnAppearing() + { + base.OnAppearing(); + this._viewModel.OnActivated(); + } + + public void OnListItemTapped(object sender, ItemTappedEventArgs e) + { + if (e.Item is TodoItem item) + { + Debug.WriteLine($"[UI] >>> Item clicked: {item.Id}"); + this._viewModel.SelectItemCommand.Execute(item); + } + + if (sender is ListView itemList) + { + itemList.SelectedItem = null; + } + } +} + diff --git a/samples/todoapp/TodoApp.MAUI/MauiProgram.cs b/samples/todoapp/TodoApp.MAUI/MauiProgram.cs new file mode 100644 index 00000000..c000581f --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/MauiProgram.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Logging; + +namespace TodoApp.MAUI; +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + MauiAppBuilder builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + +#if DEBUG + builder.Logging.AddDebug(); +#endif + + return builder.Build(); + } +} diff --git a/samples/todoapp/TodoApp.MAUI/Models/AppDbContext.cs b/samples/todoapp/TodoApp.MAUI/Models/AppDbContext.cs new file mode 100644 index 00000000..5081504a --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Models/AppDbContext.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.EntityFrameworkCore; + +namespace TodoApp.MAUI.Models; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet TodoItems => Set(); + + //protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder) + //{ + // HttpClientOptions clientOptions = new() + // { + // Endpoint = new Uri("https://YOURSITEHERE.azurewebsites.net/"), + // HttpPipeline = [new LoggingHandler()] + // }; + // _ = optionsBuilder.UseHttpClientOptions(clientOptions); + //} + + public async Task SynchronizeAsync(CancellationToken cancellationToken = default) + { + //PushResult pushResult = await this.PushAsync(cancellationToken); + //if (!pushResult.IsSuccessful) + //{ + // throw new ApplicationException($"Push failed: {pushResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + //} + + //PullResult pullResult = await this.PullAsync(cancellationToken); + //if (!pullResult.IsSuccessful) + //{ + // throw new ApplicationException($"Pull failed: {pullResult.FailedRequests.FirstOrDefault().Value.ReasonPhrase}"); + //} + } +} + +/// +/// Use this class to initialize the database. In this sample, we just create +/// the database. However, you may want to use migrations. +/// +/// The context for the database. +public class DbContextInitializer(AppDbContext context) : IDbInitializer +{ + /// + public void Initialize() + { + _ = context.Database.EnsureCreated(); + // Task.Run(async () => await context.SynchronizeAsync()); + } + + /// + public Task InitializeAsync(CancellationToken cancellationToken = default) + => context.Database.EnsureCreatedAsync(cancellationToken); +} diff --git a/samples/todoapp/TodoApp.MAUI/Models/IDbInitializer.cs b/samples/todoapp/TodoApp.MAUI/Models/IDbInitializer.cs new file mode 100644 index 00000000..3dd29360 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Models/IDbInitializer.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TodoApp.MAUI.Models; + +/// +/// An interface to initialize a database. +/// +public interface IDbInitializer +{ + /// + /// Synchronously initialize the database. + /// + void Initialize(); + + /// + /// Asynchronously initialize the database. + /// + /// A to observe. + /// A task that resolves when complete. + Task InitializeAsync(CancellationToken cancellationToken = default); +} diff --git a/samples/todoapp/TodoApp.MAUI/Models/OfflineClientEntity.cs b/samples/todoapp/TodoApp.MAUI/Models/OfflineClientEntity.cs new file mode 100644 index 00000000..f4f07cdd --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Models/OfflineClientEntity.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel.DataAnnotations; + +namespace TodoApp.MAUI.Models; + +/// +/// An abstract class for working with offline entities. +/// +public abstract class OfflineClientEntity +{ + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public DateTimeOffset? UpdatedAt { get; set; } + public string? Version { get; set; } + public bool Deleted { get; set; } +} diff --git a/samples/todoapp/TodoApp.MAUI/Models/TodoItem.cs b/samples/todoapp/TodoApp.MAUI/Models/TodoItem.cs new file mode 100644 index 00000000..54557cfe --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Models/TodoItem.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TodoApp.MAUI.Models; + +public class TodoItem : OfflineClientEntity +{ + public string Title { get; set; } = string.Empty; + public bool IsComplete { get; set; } = false; +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Android/AndroidManifest.xml b/samples/todoapp/TodoApp.MAUI/Platforms/Android/AndroidManifest.xml new file mode 100644 index 00000000..e9937ad7 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainActivity.cs b/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainActivity.cs new file mode 100644 index 00000000..ea6f382a --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainActivity.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace TodoApp.MAUI; +[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/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainApplication.cs b/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainApplication.cs new file mode 100644 index 00000000..e7e73a5a --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Android/MainApplication.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Android.App; +using Android.Runtime; + +namespace TodoApp.MAUI; +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Android/Resources/values/colors.xml b/samples/todoapp/TodoApp.MAUI/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 00000000..c04d7492 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/AppDelegate.cs b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 00000000..82815a29 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Foundation; + +namespace TodoApp.MAUI; +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Entitlements.plist b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 00000000..de4adc94 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,14 @@ + + + + + + + com.apple.security.app-sandbox + + + com.apple.security.network.client + + + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Info.plist b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Info.plist new file mode 100644 index 00000000..72689771 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + UIDeviceFamily + + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Program.cs b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Program.cs new file mode 100644 index 00000000..73a49570 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ObjCRuntime; +using UIKit; + +namespace TodoApp.MAUI; +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/Main.cs b/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/Main.cs new file mode 100644 index 00000000..5e9b619b --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/Main.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Maui; +using Microsoft.Maui.Hosting; +using System; + +namespace TodoApp.MAUI; + +internal class Program : MauiApplication +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + static void Main(string[] args) + { + var app = new Program(); + app.Run(args); + } +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/tizen-manifest.xml b/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/tizen-manifest.xml new file mode 100644 index 00000000..bf392494 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Tizen/tizen-manifest.xml @@ -0,0 +1,15 @@ + + + + + + maui-appicon-placeholder + + + + + http://tizen.org/privilege/internet + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml new file mode 100644 index 00000000..1a6b9393 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml @@ -0,0 +1,8 @@ + + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml.cs b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml.cs new file mode 100644 index 00000000..e5466cf7 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/App.xaml.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace TodoApp.MAUI.WinUI; +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : MauiWinUIApplication +{ + /// + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). + /// + public App() + { + this.InitializeComponent(); + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Windows/Package.appxmanifest b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/Package.appxmanifest new file mode 100644 index 00000000..e38c09b0 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,46 @@ + + + + + + + + + $placeholder$ + User Name + $placeholder$.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/Windows/app.manifest b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/app.manifest new file mode 100644 index 00000000..23739960 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/Windows/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/iOS/AppDelegate.cs b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/AppDelegate.cs new file mode 100644 index 00000000..82815a29 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Foundation; + +namespace TodoApp.MAUI; +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Info.plist b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Info.plist new file mode 100644 index 00000000..0004a4fd --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Info.plist @@ -0,0 +1,32 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Program.cs b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Program.cs new file mode 100644 index 00000000..73a49570 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Program.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ObjCRuntime; +using UIKit; + +namespace TodoApp.MAUI; +public class Program +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..24ab3b43 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Platforms/iOS/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,51 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + diff --git a/samples/todoapp/TodoApp.MAUI/Properties/launchSettings.json b/samples/todoapp/TodoApp.MAUI/Properties/launchSettings.json new file mode 100644 index 00000000..edf8aadc --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "MsixPackage", + "nativeDebugging": false + } + } +} \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appicon.svg b/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appicon.svg new file mode 100644 index 00000000..9d63b651 --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appiconfg.svg b/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appiconfg.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Regular.ttf b/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Regular.ttf new file mode 100644 index 00000000..2291fcbc Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Regular.ttf differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Semibold.ttf b/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Semibold.ttf new file mode 100644 index 00000000..3cbe2f8e Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Fonts/OpenSans-Semibold.ttf differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Images/additem.png b/samples/todoapp/TodoApp.MAUI/Resources/Images/additem.png new file mode 100644 index 00000000..33664793 Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Images/additem.png differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Images/completed.png b/samples/todoapp/TodoApp.MAUI/Resources/Images/completed.png new file mode 100644 index 00000000..8ac0e7f1 Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Images/completed.png differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Images/dotnet_bot.png b/samples/todoapp/TodoApp.MAUI/Resources/Images/dotnet_bot.png new file mode 100644 index 00000000..f93ce025 Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Images/dotnet_bot.png differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Images/refresh.png b/samples/todoapp/TodoApp.MAUI/Resources/Images/refresh.png new file mode 100644 index 00000000..51748edd Binary files /dev/null and b/samples/todoapp/TodoApp.MAUI/Resources/Images/refresh.png differ diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Raw/AboutAssets.txt b/samples/todoapp/TodoApp.MAUI/Resources/Raw/AboutAssets.txt new file mode 100644 index 00000000..89dc758d --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/Raw/AboutAssets.txt @@ -0,0 +1,15 @@ +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories). Deployment of the asset to your application +is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. + + + +These files will be deployed with your package and will be accessible using Essentials: + + async Task LoadMauiAsset() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); + using var reader = new StreamReader(stream); + + var contents = reader.ReadToEnd(); + } diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Splash/splash.svg b/samples/todoapp/TodoApp.MAUI/Resources/Splash/splash.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Styles/Colors.xaml b/samples/todoapp/TodoApp.MAUI/Resources/Styles/Colors.xaml new file mode 100644 index 00000000..43ebcded --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/Styles/Colors.xaml @@ -0,0 +1,44 @@ + + + + + #512BD4 + #DFD8F7 + #2B0B98 + White + Black + #E1E1E1 + #C8C8C8 + #ACACAC + #919191 + #6E6E6E + #404040 + #212121 + #141414 + + + + + + + + + + + + + + + #F7B548 + #FFD590 + #FFE5B9 + #28C2D1 + #7BDDEF + #C3F2F4 + #3E8EED + #72ACF1 + #A7CBF6 + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Resources/Styles/Styles.xaml b/samples/todoapp/TodoApp.MAUI/Resources/Styles/Styles.xaml new file mode 100644 index 00000000..4d66d4ad --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Resources/Styles/Styles.xaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/todoapp/TodoApp.MAUI/Services/AlertService.cs b/samples/todoapp/TodoApp.MAUI/Services/AlertService.cs new file mode 100644 index 00000000..923e459d --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/Services/AlertService.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TodoApp.MAUI.Services; +public interface IAlertService +{ + Task ShowErrorAlertAsync(string title, string message, string cancel = "OK"); +} + +public class AlertService : IAlertService +{ + public Task ShowErrorAlertAsync(string title, string message, string cancel = "OK") + => Application.Current!.MainPage!.DisplayAlert(title, message, cancel); +} diff --git a/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj b/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj new file mode 100644 index 00000000..bde2871c --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj @@ -0,0 +1,75 @@ + + + + net8.0-android;net8.0-ios;net8.0-maccatalyst + $(TargetFrameworks);net8.0-windows10.0.19041.0 + + + + + + + Exe + TodoApp.MAUI + true + true + enable + enable + + + TodoApp.MAUI + + + com.companyname.todoapp.maui + + + 1.0 + 1 + + 11.0 + 13.1 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + 6.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs b/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..2f3ea0da --- /dev/null +++ b/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Client; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Windows.Input; +using TodoApp.MAUI.Models; +using TodoApp.MAUI.Services; + +namespace TodoApp.MAUI.ViewModels; + +public class MainViewModel(AppDbContext context, IAlertService alertService) : INotifyPropertyChanged +{ + private bool _isRefreshing = false; + + public ICommand AddItemCommand + => new Command(async (Entry entry) => await AddItemAsync(entry.Text)); + + public ICommand RefreshItemsCommand + => new Command(async () => await RefreshItemsAsync()); + + public ICommand SelectItemCommand + => new Command(async (TodoItem item) => await UpdateItemAsync(item.Id, !item.IsComplete)); + + public ConcurrentObservableCollection Items { get; } = new(); + + public bool IsRefreshing + { + get => this._isRefreshing; + set => SetProperty(ref this._isRefreshing, value, nameof(IsRefreshing)); + } + + public async void OnActivated() + { + await RefreshItemsAsync(); + } + + public async Task RefreshItemsAsync() + { + if (IsRefreshing) + { + return; + } + + try + { + await context.SynchronizeAsync(); + List items = await context.TodoItems.ToListAsync(); + Items.ReplaceAll(items); + } + catch (Exception ex) + { + await alertService.ShowErrorAlertAsync("RefreshItems", ex.Message); + } + finally + { + IsRefreshing = false; + } + } + + public async Task UpdateItemAsync(string itemId, bool isComplete) + { + try + { + TodoItem? item = await context.TodoItems.FindAsync([itemId]); + if (item is not null) + { + item.IsComplete = isComplete; + _ = context.TodoItems.Update(item); + _ = Items.ReplaceIf(x => x.Id == itemId, item); + _ = await context.SaveChangesAsync(); + } + } + catch (Exception ex) + { + await alertService.ShowErrorAlertAsync("UpdateItem", ex.Message); + } + } + + public async Task AddItemAsync(string text) + { + try + { + TodoItem item = new() { Title = text }; + _ = context.TodoItems.Add(item); + _ = await context.SaveChangesAsync(); + Items.Add(item); + } + catch (Exception ex) + { + await alertService.ShowErrorAlertAsync("AddItem", ex.Message); + } + } + + #region INotifyPropertyChanged + /// + /// The event handler required by + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Sets a backing store value and notify watchers of the change. The type must + /// implement for proper comparisons. + /// + /// The type of the value + /// The backing store + /// The new value + /// + protected void SetProperty(ref T storage, T value, string? propertyName = null) where T : notnull + { + if (!storage.Equals(value)) + { + storage = value; + NotifyPropertyChanged(propertyName); + } + } + + /// + /// Notifies the data context that the property named has changed value. + /// + /// The name of the property + protected void NotifyPropertyChanged(string? propertyName = null) + { + if (propertyName != null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + #endregion +}