diff --git a/README.md b/README.md index f71ef32..e3ce5b8 100644 --- a/README.md +++ b/README.md @@ -24,53 +24,56 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou - [demos/CommandLine](./demos/CommandLine/README.md): A CLI-based app demonstrating real-time data sync - [demos/WPF](./demos/WPF/README.md): A Windows desktop to-do list app real-time data sync - [demos/MAUITodo](./demos/MAUITodo/README.md): A cross-platform mobile and desktop to-do list app built with .NET MAUI, running on iOS, Android and Windows +- [demos/UnoTodo](./demos/UnoTodo/ReadMe.md): A cross-platform mobile and desktop to-do list app built with Uno Platform # Supported Frameworks ## Target Frameworks This PowerSync SDK supports the following target frameworks: + - **.NET 9** - [Latest version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) - **.NET 6** - supported for compatibility with older projects -- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below. +- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below. - .NET Framework 4.8: - - To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file: - - ```xml - - ... - - win-x86;win-x64 - win-x64 - - - - ... - - - - ``` - - and create a `IsExternalInit.cs` file in your project with the following contents: - - ```cs - using System.ComponentModel; - - namespace System.Runtime.CompilerServices - { - [EditorBrowsable(EditorBrowsableState.Never)] - internal class IsExternalInit { } - } - ``` - -------- + + To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file: + + ```xml + + ... + + win-x86;win-x64 + win-x64 + + + + ... + + + + ``` + + and create a `IsExternalInit.cs` file in your project with the following contents: + + ```cs + using System.ComponentModel; + + namespace System.Runtime.CompilerServices + { + [EditorBrowsable(EditorBrowsableState.Never)] + internal class IsExternalInit { } + } + ``` + +--- When running commands such as `dotnet run` or `dotnet test`, you may need to specify the target framework explicitly using the `--framework` flag. ## Application Frameworks + - MAUI - Cross-platform native apps for mobile and desktop - Platforms: iOS, Android, Windows - WPF - Windows desktop applications @@ -94,7 +97,7 @@ This project uses [EditorConfig](https://editorconfig.org/) for formatting, whic Download PowerSync extension ```bash -dotnet run --project Tools/Setup +dotnet run --project Tools/Setup ``` Install dependencies @@ -114,11 +117,12 @@ dotnet test -v n --framework net8.0 Run a specific test ```bash -dotnet test -v n --framework net8.0 --filter "test-file-pattern" +dotnet test -v n --framework net8.0 --filter "test-file-pattern" ``` ### Integration Tests -Integration tests in `PowerSync.Common.IntegrationTests` are intended to run against the [self-host nodejs demo](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs). + +Integration tests in `PowerSync.Common.IntegrationTests` are intended to run against the [self-host nodejs demo](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs). The integration tests are disabled by default, define the following environment variable when running the tests. ```bash @@ -132,6 +136,7 @@ RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net8.0 --filter "Categor ``` ### Performance Tests + Performance tests in `PowerSync.Common.PerformanceTests` are disabled by default, define the following environment variable when running the tests. ```bash @@ -145,6 +150,7 @@ RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net8.0 --filter "Categor ``` ## Using the PowerSync.Common package in your project + ```bash dotnet add package PowerSync.Common ``` diff --git a/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs b/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs index cd3713f..d199c98 100644 --- a/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs +++ b/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs @@ -5,5 +5,5 @@ namespace MAUITodo; [Register("AppDelegate")] public class AppDelegate : MauiUIApplicationDelegate { - protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); } diff --git a/demos/MAUITodo/Platforms/MacCatalyst/Program.cs b/demos/MAUITodo/Platforms/MacCatalyst/Program.cs index 838545d..444319f 100644 --- a/demos/MAUITodo/Platforms/MacCatalyst/Program.cs +++ b/demos/MAUITodo/Platforms/MacCatalyst/Program.cs @@ -1,15 +1,16 @@ using ObjCRuntime; + using UIKit; namespace MAUITodo; 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)); - } + // 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/demos/UnoTodo/App.xaml b/demos/UnoTodo/App.xaml new file mode 100644 index 0000000..b70b4c6 --- /dev/null +++ b/demos/UnoTodo/App.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/demos/UnoTodo/App.xaml.cs b/demos/UnoTodo/App.xaml.cs new file mode 100644 index 0000000..2c35640 --- /dev/null +++ b/demos/UnoTodo/App.xaml.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; + +using Uno.Resizetizer; + +namespace UnoTodo; + +public partial class App : Application +{ + /// + /// 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() + { + InitializeComponent(); + } + + protected Window? MainWindow { get; private set; } + public IHost? Host { get; private set; } + + // [SuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Uno.Extensions APIs are used in a way that is safe for trimming in this template context.")] + protected async override void OnLaunched(LaunchActivatedEventArgs args) + { + var builder = this.CreateBuilder(args) + // Add navigation support for toolkit controls such as TabBar and NavigationView + .UseToolkitNavigation() + .Configure(host => host +#if DEBUG + // Switch to Development environment when running in DEBUG + .UseEnvironment(Environments.Development) +#endif + .UseLogging(configure: (context, logBuilder) => + // Configure log levels for different categories of logging + logBuilder + .SetMinimumLevel( + context.HostingEnvironment.IsDevelopment() ? + LogLevel.Information : + LogLevel.Warning) + + // Default filters for core Uno Platform namespaces + .CoreLogLevel(LogLevel.Warning), enableUnoLogging: true) + .UseConfiguration(configure: builder => builder.EmbeddedSource()) + .ConfigureServices((context, services) => services.AddSingleton()) + .UseNavigation(ReactiveViewModelMappings.ViewModelMappings, RegisterRoutes) + ); + MainWindow = builder.Window; + +#if DEBUG + MainWindow.UseStudio(); +#endif + MainWindow.SetWindowIcon(); + + Host = await builder.NavigateAsync(); + } + + private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) + { + views.Register( + new ViewMap(ViewModel: typeof(ShellModel)), + new ViewMap(), + new ViewMap(), + new ViewMap(), + new DataViewMap() + ); + + routes.Register( + new RouteMap("", View: views.FindByViewModel(), + Nested: + [ + new ("Main", View: views.FindByViewModel(), IsDefault:true, + Nested: + [ + new ("Lists", View: views.FindByViewModel(), IsDefault:true), + new ("SqlConsole", View: views.FindByViewModel()), + ] + ), + new ("Todo", View: views.FindByViewModel()), + ] + ) + ); + } +} diff --git a/demos/UnoTodo/Assets/Icons/icon.svg b/demos/UnoTodo/Assets/Icons/icon.svg new file mode 100644 index 0000000..a15af53 --- /dev/null +++ b/demos/UnoTodo/Assets/Icons/icon.svg @@ -0,0 +1,42 @@ + + + + + + diff --git a/demos/UnoTodo/Assets/Icons/icon_foreground.svg b/demos/UnoTodo/Assets/Icons/icon_foreground.svg new file mode 100644 index 0000000..8ffc41a --- /dev/null +++ b/demos/UnoTodo/Assets/Icons/icon_foreground.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/UnoTodo/Assets/SharedAssets.md b/demos/UnoTodo/Assets/SharedAssets.md new file mode 100644 index 0000000..b1cc4e7 --- /dev/null +++ b/demos/UnoTodo/Assets/SharedAssets.md @@ -0,0 +1,32 @@ +# Shared Assets + +See documentation about assets here: https://github.com/unoplatform/uno/blob/master/doc/articles/features/working-with-assets.md + +## Here is a cheat sheet + +1. Add the image file to the `Assets` directory of a shared project. +2. Set the build action to `Content`. +3. (Recommended) Provide an asset for various scales/dpi + +### Examples + +```text +\Assets\Images\logo.scale-100.png +\Assets\Images\logo.scale-200.png +\Assets\Images\logo.scale-400.png + +\Assets\Images\scale-100\logo.png +\Assets\Images\scale-200\logo.png +\Assets\Images\scale-400\logo.png +``` + +### Table of scales + +| Scale | WinUI | iOS | Android | +|-------|:-----------:|:---------------:|:-------:| +| `100` | scale-100 | @1x | mdpi | +| `125` | scale-125 | N/A | N/A | +| `150` | scale-150 | N/A | hdpi | +| `200` | scale-200 | @2x | xhdpi | +| `300` | scale-300 | @3x | xxhdpi | +| `400` | scale-400 | N/A | xxxhdpi | diff --git a/demos/UnoTodo/Assets/Splash/splash_screen.svg b/demos/UnoTodo/Assets/Splash/splash_screen.svg new file mode 100644 index 0000000..8ffc41a --- /dev/null +++ b/demos/UnoTodo/Assets/Splash/splash_screen.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs b/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs new file mode 100644 index 0000000..c5ee6ce --- /dev/null +++ b/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs @@ -0,0 +1,33 @@ +using PowerSync.Common.Attachments; + +namespace UnoTodo.Attachments; + +public class NodeRemoteStorageAdapter(HttpClient client, string backendUrl) : IRemoteStorageAdapter +{ + private readonly HttpClient _client = client; + private readonly string _backendUrl = backendUrl; + + public async Task UploadFileAsync(Stream fileData, Attachment attachment) + { + var content = new StreamContent(fileData); + content.Headers.ContentType = new(attachment.MediaType ?? "application/octet-stream"); + var response = await _client.PutAsync($"{_backendUrl}/api/attachments/{attachment.Id}", content); + response.EnsureSuccessStatusCode(); + } + + public async Task DownloadFileAsync(Attachment attachment) + { + var response = await _client.GetAsync($"{_backendUrl}/api/attachments/{attachment.Id}"); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return Stream.Null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(); + } + + public async Task DeleteFileAsync(Attachment attachment) + { + var response = await _client.DeleteAsync($"{_backendUrl}/api/attachments/{attachment.Id}"); + if (response.StatusCode != System.Net.HttpStatusCode.NotFound) + response.EnsureSuccessStatusCode(); + } +} diff --git a/demos/UnoTodo/Data/AppPaths.cs b/demos/UnoTodo/Data/AppPaths.cs new file mode 100644 index 0000000..52d1fcb --- /dev/null +++ b/demos/UnoTodo/Data/AppPaths.cs @@ -0,0 +1,9 @@ +namespace UnoTodo.Data; + +public static class AppPaths +{ + // Deliberately not ApplicationData.Current, which requires packaged app identity that + // an unpackaged Uno Skia desktop app does not have. + public static string AppDataDir { get; } = Directory.CreateDirectory( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UnoTodo")).FullName; +} diff --git a/demos/UnoTodo/Data/AppSchema.cs b/demos/UnoTodo/Data/AppSchema.cs new file mode 100644 index 0000000..918c63f --- /dev/null +++ b/demos/UnoTodo/Data/AppSchema.cs @@ -0,0 +1,13 @@ +using PowerSync.Common.Attachments; +using PowerSync.Common.DB.Schema; + +namespace UnoTodo.Data; + +class AppSchema +{ + public static Table Todos = new(typeof(TodoItem)); + public static Table Lists = new(typeof(TodoList)); + public static Table Attachments = new(typeof(Attachment)); + + public static Schema PowerSyncSchema = new(Todos, Lists, Attachments); +} diff --git a/demos/UnoTodo/Data/AppServices.cs b/demos/UnoTodo/Data/AppServices.cs new file mode 100644 index 0000000..1efa03f --- /dev/null +++ b/demos/UnoTodo/Data/AppServices.cs @@ -0,0 +1,7 @@ +namespace UnoTodo.Data; + +public static class AppServices +{ + public static PowerSyncData PowerSyncData => + ((App)Application.Current).Host!.Services.GetRequiredService(); +} diff --git a/demos/UnoTodo/Data/NodeConnector.cs b/demos/UnoTodo/Data/NodeConnector.cs new file mode 100644 index 0000000..781beee --- /dev/null +++ b/demos/UnoTodo/Data/NodeConnector.cs @@ -0,0 +1,118 @@ +using System.Text; +using System.Text.Json; + +using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; +using PowerSync.Common.DB.Crud; + +namespace UnoTodo.Data; + +public class NodeConnector : IPowerSyncBackendConnector +{ + private static string StorageFilePath => Path.Combine(AppPaths.AppDataDir, "user_id.txt"); + + private readonly HttpClient _httpClient; + + public string BackendUrl { get; } + public string PowerSyncUrl { get; } + public string UserId { get; private set; } + + public NodeConnector() + { + _httpClient = new HttpClient(); + + // Load or generate User ID + UserId = LoadOrGenerateUserId(); + +#if ANDROID + BackendUrl = "http://10.0.2.2:6060"; + PowerSyncUrl = "http://10.0.2.2:8080"; +#else + BackendUrl = "http://localhost:6060"; + PowerSyncUrl = "http://localhost:8080"; +#endif + } + + public static 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); + + return jsonResponse == null || !jsonResponse.TryGetValue("token", out string? token) + ? throw new Exception("Invalid response received from authentication endpoint.") + : new PowerSyncCredentials(PowerSyncUrl, 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; + } + + 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/UnoTodo/Data/PowerSyncData.cs b/demos/UnoTodo/Data/PowerSyncData.cs new file mode 100644 index 0000000..2da4b3a --- /dev/null +++ b/demos/UnoTodo/Data/PowerSyncData.cs @@ -0,0 +1,212 @@ +using System.Runtime.CompilerServices; + +using PowerSync.Common.Attachments; +using PowerSync.Common.Client; +using PowerSync.Common.MDSQLite; +using PowerSync.Maui.SQLite; + +using UnoTodo.Attachments; + +namespace UnoTodo.Data; + +public class PowerSyncData +{ + public PowerSyncDatabase Db; + public AttachmentQueue PhotoAttachmentQueue { get; } + private string UserId { get; } + + public PowerSyncData() + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Error); + }); + var logger = loggerFactory.CreateLogger("PowerSyncLogger"); + + var dbPath = Path.Combine(AppPaths.AppDataDir, "example.db"); + var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() + { + DbFilename = dbPath + }); + Db = new PowerSyncDatabase(new PowerSyncDatabaseOptions() + { + Database = factory, + Schema = AppSchema.PowerSyncSchema, + Logger = logger + }); + + var nodeConnector = new NodeConnector(); + UserId = nodeConnector.UserId; + + Db.Connect(nodeConnector); + + var attachmentsDir = Path.Combine(AppPaths.AppDataDir, "attachments"); + var localStorage = new FileManagerLocalStorage(attachmentsDir); + var remoteStorage = new NodeRemoteStorageAdapter(new HttpClient(), nodeConnector.BackendUrl); + + PhotoAttachmentQueue = new AttachmentQueue(new AttachmentQueueOptions + { + Db = Db, + LocalStorage = localStorage, + RemoteStorage = remoteStorage, + WatchAttachments = ct => WatchTodoPhotos(Db, ct), + }); + + _ = Task.Run(() => PhotoAttachmentQueue.StartSyncAsync()); + } + + private static async IAsyncEnumerable WatchTodoPhotos( + PowerSyncDatabase db, + [EnumeratorCancellation] CancellationToken ct = default) + { + var stream = db.Watch( + "SELECT photo_id FROM todos WHERE photo_id IS NOT NULL", + [], + new SQLWatchOptions { TriggerImmediately = true, Signal = ct }); + await foreach (var rows in stream.WithCancellation(ct)) + { + yield return [.. rows.Select(r => new WatchedAttachmentItem(r.photo_id, fileExtension: "jpg"))]; + } + } + + private record PhotoIdResult(string photo_id); + + public async Task SaveListAsync(TodoList list) + { + if (list.ID != "") + { + await Db.Execute( + "UPDATE lists SET name = ?, owner_id = ? WHERE id = ?", + [list.Name, UserId, list.ID]); + } + else + { + await Db.Execute( + "INSERT INTO lists (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)", + [list.Name, UserId]); + } + } + + public async Task DeleteListAsync(TodoList list) + { + await Db.WriteTransaction(async tx => + { + // Prevent attachments from being orphaned when their owning todo is deleted; + // `has_synced = 0` prevents the attachments from becoming archived instead of deleted. + // + // It would be nice to be able to do this sort of bulk-delete using the attachments API directly. + await tx.Execute( + @"UPDATE attachments + SET state = ?, has_synced = 0 + WHERE id IN (SELECT photo_id FROM todos WHERE list_id = ? AND photo_id IS NOT NULL)", + [(int)AttachmentState.QueuedDelete, list.ID]); + await tx.Execute("DELETE FROM todos WHERE list_id = ?", [list.ID]); + await tx.Execute("DELETE FROM lists WHERE id = ?", [list.ID]); + }); + + // Force the attachments queue to acknowledge the changes immediately + PhotoAttachmentQueue.TriggerSync(); + } + + public async Task SaveItemAsync(TodoItem item) + { + if (item.ID != "") + { + await Db.Execute( + @"UPDATE todos + SET description = ?, completed = ?, completed_at = ?, completed_by = ? + WHERE id = ?", + [ + item.Description, + item.Completed, + item.CompletedAt, + item.Completed ? UserId : null, + item.ID + ]); + } + else + { + await Db.Execute( + @"INSERT INTO todos + (id, list_id, description, created_at, created_by, completed, completed_at, completed_by) + VALUES (uuid(), ?, ?, datetime(), ?, ?, ?, ?)", + [ + item.ListId, + item.Description, + UserId, + item.Completed ? 1 : 0, + item.CompletedAt!, + item.Completed ? UserId : null + ]); + } + } + + public async Task SaveTodoPhotoAsync(string todoId, Stream photoData) + { + await PhotoAttachmentQueue.SaveFileAsync( + data: photoData, + fileExtension: "jpg", + mediaType: "image/jpeg", + updateHook: (tx, attachment) => + tx.Execute("UPDATE todos SET photo_id = ? WHERE id = ?", [attachment.Id, todoId])); + } + + public async Task RemoveTodoPhotoAsync(string todoId, string photoId) + { + await PhotoAttachmentQueue.DeleteFileAsync( + id: photoId, + updateHook: (tx, _) => + tx.Execute("UPDATE todos SET photo_id = NULL WHERE id = ?", [todoId])); + } + + public async Task SaveTodoCompletedAsync(string todoId, bool completed) + { + if (completed) + { + await Db.Execute( + @"UPDATE todos + SET completed = 1, completed_at = datetime(), completed_by = ? + WHERE id = ?", + [ + UserId, + todoId + ]); + } + else + { + await Db.Execute( + @"UPDATE todos + SET completed = 0, completed_at = NULL, completed_by = NULL + WHERE id = ?", + [ + todoId + ]); + } + } + + public async Task DeleteItemAsync(TodoItem item) + { + if (item.PhotoId != null) + { + // DeleteFileAsync can fail if a `todos` row is synced with a photo_id, but the + // corresponding `attachments` row/item hasn't been created yet. Fallback to regular + // `DELETE FROM todos`. + try + { + await PhotoAttachmentQueue.DeleteFileAsync( + id: item.PhotoId, + updateHook: (tx, _) => + tx.Execute("DELETE FROM todos WHERE id = ?", [item.ID])); + } + catch + { + await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]); + } + } + else + { + await Db.Execute("DELETE FROM todos WHERE id = ?", [item.ID]); + } + } +} diff --git a/demos/UnoTodo/GlobalUsings.cs b/demos/UnoTodo/GlobalUsings.cs new file mode 100644 index 0000000..2d32654 --- /dev/null +++ b/demos/UnoTodo/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using System.Collections.Immutable; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; + +global using UnoTodo.Data; +global using UnoTodo.Models; +global using UnoTodo.Presentation; +global using UnoTodo.Services; + +global using ApplicationExecutionState = Windows.ApplicationModel.Activation.ApplicationExecutionState; +[assembly: Uno.Extensions.Reactive.Config.BindableGenerationTool(3)] diff --git a/demos/UnoTodo/Models/TodoItem.cs b/demos/UnoTodo/Models/TodoItem.cs new file mode 100644 index 0000000..38bf43d --- /dev/null +++ b/demos/UnoTodo/Models/TodoItem.cs @@ -0,0 +1,53 @@ +namespace UnoTodo.Models; + +using PowerSync.Common.DB.Schema.Attributes; + +[ + Table("todos", IgnoreEmptyUpdates = true), + Index("list", ["list_id"]) +] +public class TodoItem +{ + [Column("id")] + public string ID { get; set; } = ""; + + [Column("list_id")] + public string ListId { get; set; } = null!; + + [Column("created_at")] + public string CreatedAt { get; set; } = null!; + + [Column("completed_at")] + public string? CompletedAt { get; set; } + + [Column("description")] + public string Description { get; set; } = null!; + + [Column("created_by")] + public string CreatedBy { get; set; } = null!; + + [Column("completed_by")] + public string CompletedBy { get; set; } = null!; + + [Column("completed")] + public bool Completed { get; set; } + + [Column("photo_id")] + public string? PhotoId { get; set; } + + // Display-only properties. [Ignored] keeps them out of the PowerSync schema (they are not + // real columns on the `todos` table), while [Column] still lets Dapper hydrate PhotoLocalUri + // from the `photo_local_uri` alias produced by the attachments LEFT JOIN in the watch query. + [Ignored] + [Column("photo_local_uri")] + public string? PhotoLocalUri { get; set; } + + [Ignored] + public bool HasNoPhoto => PhotoId == null; + + [Ignored] + public bool IsDownloading => PhotoId != null && PhotoLocalUri == null; + + [Ignored] + public bool IsPhotoReady => PhotoLocalUri != null; +} diff --git a/demos/UnoTodo/Models/TodoList.cs b/demos/UnoTodo/Models/TodoList.cs new file mode 100644 index 0000000..07630ab --- /dev/null +++ b/demos/UnoTodo/Models/TodoList.cs @@ -0,0 +1,19 @@ +namespace UnoTodo.Models; + +using PowerSync.Common.DB.Schema.Attributes; + +[Table("lists", IgnoreEmptyUpdates = true)] +public class TodoList +{ + [Column("id")] + public string ID { get; set; } = ""; + + [Column("created_at")] + public string CreatedAt { get; set; } = null!; + + [Column("name")] + public string Name { get; set; } = null!; + + [Column("owner_id")] + public string OwnerId { get; set; } = null!; +} diff --git a/demos/UnoTodo/Package.appxmanifest b/demos/UnoTodo/Package.appxmanifest new file mode 100644 index 0000000..9ef3814 --- /dev/null +++ b/demos/UnoTodo/Package.appxmanifest @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/UnoTodo/Platforms/Android/AndroidManifest.xml b/demos/UnoTodo/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..41b2ab9 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/demos/UnoTodo/Platforms/Android/Assets/AboutAssets.txt b/demos/UnoTodo/Platforms/Android/Assets/AboutAssets.txt new file mode 100644 index 0000000..89ab409 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Assets/AboutAssets.txt @@ -0,0 +1,22 @@ +To add cross-platform image assets for your Uno Platform app, use the Assets folder +in the shared project instead. Assets in this folder are Android-only assets. + +Any raw assets you want to be deployed with your application can be placed in +this directory (and child directories) and given a Build Action of "AndroidAsset". + +These files will be deployed with your package and will be accessible using Android's +AssetManager, like this: + +public class ReadAsset : Activity +{ + protected override void OnCreate (Bundle bundle) + { + base.OnCreate (bundle); + + InputStream input = Assets.Open ("my_asset.txt"); + } +} + +Additionally, some Android functions will automatically load asset files: + +Typeface tf = Typeface.CreateFromAsset (Context.Assets, "fonts/samplefont.ttf"); diff --git a/demos/UnoTodo/Platforms/Android/CameraCapture.Android.cs b/demos/UnoTodo/Platforms/Android/CameraCapture.Android.cs new file mode 100644 index 0000000..bb72ab8 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/CameraCapture.Android.cs @@ -0,0 +1,86 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Provider; + +using AndroidX.Core.Content; + +namespace UnoTodo.Services; + +public static partial class CameraCapture +{ + // Must match the provider authority declared in Platforms/Android/AndroidManifest.xml. + private const string FileProviderAuthority = "com.powersync.unotodo.fileprovider"; + private const int CaptureRequestCode = 0x0CA3; + + private static TaskCompletionSource? pendingCapture; + + private static partial bool GetIsAvailable() + { + if (Uno.UI.ContextHelper.Current is not Context { PackageManager: { } packageManager }) + { + return false; + } + + // ResolveActivity only sees the camera app thanks to the entry in the manifest. + return packageManager.HasSystemFeature(PackageManager.FeatureCameraAny) + && new Intent(MediaStore.ActionImageCapture).ResolveActivity(packageManager) != null; + } + + private static async partial Task CaptureCoreAsync() + { + if (Uno.UI.ContextHelper.Current is not Activity activity) + { + return null; + } + + // App-private external storage, shared with the camera app through a FileProvider grant, + // so neither the CAMERA nor any storage permission is needed. + var directory = new Java.IO.File(activity.GetExternalFilesDir(null), "Pictures"); + directory.Mkdirs(); + var file = new Java.IO.File(directory, $"capture-{Guid.NewGuid():N}.jpg"); + + try + { + if (FileProvider.GetUriForFile(activity, FileProviderAuthority, file) is not { } outputUri) + { + return null; + } + + var intent = new Intent(MediaStore.ActionImageCapture); + intent.PutExtra(MediaStore.ExtraOutput, (Android.OS.IParcelable)outputUri); + intent.AddFlags(ActivityFlags.GrantWriteUriPermission); + + var completion = new TaskCompletionSource(); + pendingCapture = completion; + + activity.StartActivityForResult(intent, CaptureRequestCode); + + if (!await completion.Task || !file.Exists() || file.Length() == 0) + { + return null; + } + + return new MemoryStream(await File.ReadAllBytesAsync(file.AbsolutePath!)); + } + finally + { + pendingCapture = null; + if (file.Exists()) + { + file.Delete(); + } + } + } + + /// + /// Called from MainActivity's OnActivityResult to resume a pending capture. + /// + internal static void OnActivityResult(int requestCode, Result resultCode) + { + if (requestCode == CaptureRequestCode) + { + pendingCapture?.TrySetResult(resultCode == Result.Ok); + } + } +} diff --git a/demos/UnoTodo/Platforms/Android/Main.Android.cs b/demos/UnoTodo/Platforms/Android/Main.Android.cs new file mode 100644 index 0000000..81e4b07 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Main.Android.cs @@ -0,0 +1,18 @@ +using Android.App; +using Android.Runtime; + +namespace UnoTodo.Droid; + +[Application( + Label = "@string/ApplicationName", + Icon = "@mipmap/icon", + LargeHeap = true, + HardwareAccelerated = true, + Theme = "@style/Theme.App.Starting" +)] +public class Application : NativeApplication +{ + public Application(IntPtr javaReference, JniHandleOwnership transfer) + : base(() => new App(), javaReference, transfer) { } +} + diff --git a/demos/UnoTodo/Platforms/Android/MainActivity.Android.cs b/demos/UnoTodo/Platforms/Android/MainActivity.Android.cs new file mode 100644 index 0000000..15d00f5 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/MainActivity.Android.cs @@ -0,0 +1,28 @@ +using Android.App; +using Android.Content; +using Android.OS; +using Android.Views; + +namespace UnoTodo.Droid; + +[Activity( + MainLauncher = true, + ConfigurationChanges = ActivityHelper.AllConfigChanges, + WindowSoftInputMode = SoftInput.AdjustNothing | SoftInput.StateHidden +)] +public class MainActivity : ApplicationActivity +{ + protected override void OnCreate(Bundle? savedInstanceState) + { + AndroidX.Core.SplashScreen.SplashScreen.InstallSplashScreen(this); + + base.OnCreate(savedInstanceState); + } + + protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) + { + base.OnActivityResult(requestCode, resultCode, data); + + CameraCapture.OnActivityResult(requestCode, resultCode); + } +} diff --git a/demos/UnoTodo/Platforms/Android/Resources/AboutResources.txt b/demos/UnoTodo/Platforms/Android/Resources/AboutResources.txt new file mode 100644 index 0000000..17e3b13 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Resources/AboutResources.txt @@ -0,0 +1,47 @@ +To add cross-platform image assets for your Uno Platform app, use the Assets folder +in the shared project instead. Resources in this folder are Android-only. + +Images, layout descriptions, binary blobs and string dictionaries can be included +in your application as resource files. Various Android APIs are designed to +operate on the resource IDs instead of dealing with images, strings or binary blobs +directly. + +For example, a sample Android app that contains a user interface layout (main.axml), +an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png) +would keep its resources in the "Resources" directory of the application: + +Resources/ + drawable/ + icon.png + + layout/ + main.axml + + values/ + strings.xml + +In order to get the build system to recognize Android resources, set the build action to +"AndroidResource". The native Android APIs do not operate directly with filenames, but +instead operate on resource IDs. When you compile an Android application that uses resources, +the build system will package the resources for distribution and generate a class called "R" +(this is an Android convention) that contains the tokens for each one of the resources +included. For example, for the above Resources layout, this is what the R class would expose: + +public class R { + public class drawable { + public const int icon = 0x123; + } + + public class layout { + public const int main = 0x456; + } + + public class strings { + public const int first_string = 0xabc; + public const int second_string = 0xbcd; + } +} + +You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main +to reference the layout/main.axml file, or R.strings.first_string to reference the first +string in the dictionary file values/strings.xml. diff --git a/demos/UnoTodo/Platforms/Android/Resources/values/Strings.xml b/demos/UnoTodo/Platforms/Android/Resources/values/Strings.xml new file mode 100644 index 0000000..316b6b0 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Resources/values/Strings.xml @@ -0,0 +1,5 @@ + + + Hello World, Click Me! + UnoTodo + diff --git a/demos/UnoTodo/Platforms/Android/Resources/values/Styles.xml b/demos/UnoTodo/Platforms/Android/Resources/values/Styles.xml new file mode 100644 index 0000000..f47dcf3 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Resources/values/Styles.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/demos/UnoTodo/Platforms/Android/Resources/xml/file_paths.xml b/demos/UnoTodo/Platforms/Android/Resources/xml/file_paths.xml new file mode 100644 index 0000000..9c6976d --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/Resources/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/demos/UnoTodo/Platforms/Android/environment.conf b/demos/UnoTodo/Platforms/Android/environment.conf new file mode 100644 index 0000000..d8c1064 --- /dev/null +++ b/demos/UnoTodo/Platforms/Android/environment.conf @@ -0,0 +1,2 @@ +# See this for more details: http://developer.xamarin.com/guides/android/advanced_topics/garbage_collection/ +MONO_GC_PARAMS=bridge-implementation=new,nursery-size=32m,soft-heap-limit=256m \ No newline at end of file diff --git a/demos/UnoTodo/Platforms/Desktop/CameraCapture.Desktop.cs b/demos/UnoTodo/Platforms/Desktop/CameraCapture.Desktop.cs new file mode 100644 index 0000000..6174088 --- /dev/null +++ b/demos/UnoTodo/Platforms/Desktop/CameraCapture.Desktop.cs @@ -0,0 +1,9 @@ +namespace UnoTodo.Services; + +public static partial class CameraCapture +{ + // No desktop camera support: callers fall back to picking a photo from disk. + private static partial bool GetIsAvailable() => false; + + private static partial Task CaptureCoreAsync() => Task.FromResult(null); +} diff --git a/demos/UnoTodo/Platforms/Desktop/Program.cs b/demos/UnoTodo/Platforms/Desktop/Program.cs new file mode 100644 index 0000000..4243b96 --- /dev/null +++ b/demos/UnoTodo/Platforms/Desktop/Program.cs @@ -0,0 +1,20 @@ +using Uno.UI.Hosting; + +namespace UnoTodo; + +internal class Program +{ + [STAThread] + public static void Main(string[] args) + { + var host = UnoPlatformHostBuilder.Create() + .App(() => new App()) + .UseX11() + .UseLinuxFrameBuffer() + .UseMacOS() + .UseWin32() + .Build(); + + host.Run(); + } +} diff --git a/demos/UnoTodo/Presentation/Converters/BoolToVisibilityConverter.cs b/demos/UnoTodo/Presentation/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..7b7c925 --- /dev/null +++ b/demos/UnoTodo/Presentation/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml.Data; + +namespace UnoTodo.Presentation.Converters; + +public class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var flag = value is bool b && b; + if (string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase)) + { + flag = !flag; + } + + return flag ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} diff --git a/demos/UnoTodo/Presentation/Converters/PathToImageSourceConverter.cs b/demos/UnoTodo/Presentation/Converters/PathToImageSourceConverter.cs new file mode 100644 index 0000000..187b7bf --- /dev/null +++ b/demos/UnoTodo/Presentation/Converters/PathToImageSourceConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace UnoTodo.Presentation.Converters; + +public class PathToImageSourceConverter : IValueConverter +{ + public object? Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string path || string.IsNullOrEmpty(path)) + { + return null; + } + + return new BitmapImage { UriSource = new Uri(path, UriKind.Absolute) }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} diff --git a/demos/UnoTodo/Presentation/Dialogs.cs b/demos/UnoTodo/Presentation/Dialogs.cs new file mode 100644 index 0000000..5af12cd --- /dev/null +++ b/demos/UnoTodo/Presentation/Dialogs.cs @@ -0,0 +1,99 @@ +namespace UnoTodo.Presentation; + +public static class Dialogs +{ + public static async Task PromptTextAsync( + FrameworkElement owner, string title, string placeholder, string? initialValue = null) + { + var textBox = new TextBox + { + PlaceholderText = placeholder, + Text = initialValue ?? string.Empty, + }; + + var dialog = new ContentDialog + { + Title = title, + Content = textBox, + PrimaryButtonText = "OK", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = owner.XamlRoot, + }; + + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary ? textBox.Text : null; + } + + public static async Task ConfirmAsync( + FrameworkElement owner, string title, string message, string confirmText = "Yes", string cancelText = "No") + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + PrimaryButtonText = confirmText, + CloseButtonText = cancelText, + DefaultButton = ContentDialogButton.Close, + XamlRoot = owner.XamlRoot, + }; + + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary; + } + + public static async Task ShowErrorAsync(FrameworkElement owner, string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = "OK", + XamlRoot = owner.XamlRoot, + }; + + await dialog.ShowAsync(); + } + + /// + /// Shows an action sheet listing each non-null option as a button, returning the text of the + /// option that was pressed, or null if dismissed. + /// + public static async Task ShowActionSheetAsync( + FrameworkElement owner, string title, string cancelText, params string?[] options) + { + var choices = new StackPanel { Spacing = 8 }; + + var dialog = new ContentDialog + { + Title = title, + Content = choices, + CloseButtonText = cancelText, + XamlRoot = owner.XamlRoot, + }; + + string? selected = null; + foreach (var option in options) + { + if (string.IsNullOrEmpty(option)) + { + continue; + } + + var button = new Button + { + Content = option, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + button.Click += (_, _) => + { + selected = option; + dialog.Hide(); + }; + choices.Children.Add(button); + } + + await dialog.ShowAsync(); + return selected; + } +} diff --git a/demos/UnoTodo/Presentation/ListsModel.cs b/demos/UnoTodo/Presentation/ListsModel.cs new file mode 100644 index 0000000..8caee5f --- /dev/null +++ b/demos/UnoTodo/Presentation/ListsModel.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; + +using PowerSync.Common.Client; + +namespace UnoTodo.Presentation; + +public partial record ListsModel(PowerSyncData Data) +{ + public IListFeed Lists => ListFeed.AsyncEnumerable(WatchLists); + + public IFeed Connected => Feed.AsyncEnumerable(WatchConnected); + + private async IAsyncEnumerable> WatchLists( + [EnumeratorCancellation] CancellationToken ct) + { + var stream = Data.Db.Watch("select * from lists", null, + new SQLWatchOptions { TriggerImmediately = true, Signal = ct }); + await foreach (var rows in stream.WithCancellation(ct)) + { + yield return rows.ToImmutableList(); + } + } + + private async IAsyncEnumerable WatchConnected([EnumeratorCancellation] CancellationToken ct) + { + yield return Data.Db.Connected; + await foreach (var update in Data.Db.Events.OnStatusChanged.ListenAsync(ct)) + { + yield return update.Status.Connected; + } + } +} diff --git a/demos/UnoTodo/Presentation/ListsPage.xaml b/demos/UnoTodo/Presentation/ListsPage.xaml new file mode 100644 index 0000000..7bd8ddc --- /dev/null +++ b/demos/UnoTodo/Presentation/ListsPage.xaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + +