From f1dd38d47241342a6638e2b92388bdd82c2869be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Jul 2026 18:22:41 +0000 Subject: [PATCH 1/2] chore: publish Quickstarts for SDK version 5.0.2 --- .../actions/seed-ditto-document/action.yml | 27 +- .github/browserstack-devices.yml | 82 +- LICENSE | 2 +- README.md | 5 +- android-java/gradle/libs.versions.toml | 2 +- .../QuickStartTasks/gradle/libs.versions.toml | 2 +- cpp-tui/taskscpp/Makefile | 2 +- cpp-tui/taskscpp/src/main.cpp | 20 +- cpp-tui/taskscpp/src/tasks_log.cpp | 30 +- cpp-tui/taskscpp/src/tasks_log.h | 3 - cpp-tui/taskscpp/src/tasks_peer.cpp | 81 +- cpp-tui/taskscpp/src/tasks_peer.h | 3 +- cpp-tui/taskscpp/tests/integration_test.cpp | 142 ++-- cpp-tui/taskscpp/tests/run_tests.sh | 2 +- .../DittoMauiTasksApp.csproj | 134 ++-- dotnet-maui/DittoMauiTasksApp/MauiProgram.cs | 240 +++--- .../Platforms/MacCatalyst/AppDelegate.cs | 2 +- .../Platforms/MacCatalyst/Program.cs | 2 +- .../DittoMauiTasksApp/Utils/PopupService.cs | 28 +- .../ViewModels/TasksPageviewModel.cs | 740 +++++++++--------- .../DittoMauiTasksApp/Views/TasksPage.xaml | 186 ++--- .../DittoMauiTasksApp/Views/TasksPage.xaml.cs | 62 +- .../UITests.Android/AndroidTaskSearchTests.cs | 172 ++-- dotnet-maui/UITests.Android/Program.cs | 2 +- dotnet-maui/UITests.Shared/BaseTest.cs | 246 +++--- dotnet-maui/UITests.Shared/TaskSearchTests.cs | 174 ++-- dotnet-maui/UITests.iOS/Program.cs | 2 +- dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs | 254 +++--- .../DittoDotNetTasksConsole.Tests.csproj | 2 +- .../IntegrationTests.cs | 2 +- .../DittoDotNetTasksConsole.csproj | 2 +- .../DittoDotNetTasksConsole/TasksPeer.cs | 2 +- dotnet-winforms-net48/.env.sample | 12 +- dotnet-winforms-net48/App.config | 34 +- dotnet-winforms-net48/MainForm.Designer.cs | 328 ++++---- dotnet-winforms-net48/MainForm.cs | 506 ++++++------ dotnet-winforms-net48/MainForm.resx | 238 +++--- dotnet-winforms-net48/Program.cs | 136 ++-- .../Properties/AssemblyInfo.cs | 66 +- .../Properties/Resources.Designer.cs | 142 ++-- .../Properties/Resources.resx | 232 +++--- .../Properties/Settings.Designer.cs | 60 +- .../Properties/Settings.settings | 14 +- .../Taskapp.WinForms.Net48.csproj | 325 ++++---- .../Taskapp.WinForms.Net48.sln | 50 +- dotnet-winforms-net48/TasksPeer.cs | 580 +++++++------- dotnet-winforms-net48/ToDoTask.cs | 58 +- dotnet-winforms-net48/packages.config | 40 +- dotnet-winforms/IntegrationTest/Program.cs | 2 +- dotnet-winforms/TasksApp/DittoTasksApp.csproj | 2 +- dotnet-winforms/TasksApp/Program.cs | 2 +- electron/package.json | 2 +- electron/tailwind.config.js | 5 +- electron/tsconfig.web.json | 6 +- flutter_app/android/build.gradle | 6 + flutter_app/android/gradlew.bat | 188 ++--- flutter_app/integration_test/app_test.dart | 20 +- flutter_app/lib/dialog.dart | 20 +- flutter_app/lib/dql_builder.dart | 13 +- flutter_app/lib/main.dart | 37 +- flutter_app/pubspec.yaml | 14 +- flutter_app/test/dql_safety_test.dart | 71 ++ flutter_app/test/widget_test.dart | 16 +- global.json | 7 + java-server/build.gradle.kts | 10 +- java-server/java-spring-maven/pom.xml | 4 +- java-server/settings.gradle.kts | 2 +- javascript-tui/.prettierignore | 1 + javascript-tui/README.md | 8 +- javascript-tui/package.json | 130 ++- javascript-tui/prettier.config.cjs | 7 + javascript-tui/prettier.config.js | 9 - javascript-tui/source/app.js | 598 +++++++------- javascript-tui/source/cli.js | 134 ++-- javascript-tui/tests/integration_test.js | 156 ++-- javascript-web/package.json | 2 +- .../composeApp/build.gradle.kts | 10 +- .../quickstart/ditto/DittoManager.android.kt | 2 - .../data/repository/DittoTaskRepository.kt | 15 +- .../ditto/quickstart/ditto/DittoManager.kt | 28 +- .../usecases/SetSyncStatusUseCase.kt | 6 +- kotlin-multiplatform/detekt.yml | 133 +++- ...1-app-launch-and-seeded-tasks-android.yaml | 12 +- .../01-app-launch-and-seeded-tasks-ios.yaml | 14 +- react-native-expo/App.tsx | 219 +++--- react-native-expo/android/gradle.properties | 9 +- react-native-expo/babel.config.js | 8 +- react-native-expo/components/DittoInfo.tsx | 6 +- .../components/EditTaskModal.tsx | 25 +- react-native-expo/components/Fab.tsx | 7 +- react-native-expo/components/NewTaskModal.tsx | 22 +- react-native-expo/components/TaskDone.tsx | 6 +- react-native-expo/eslint.config.js | 6 +- react-native-expo/main.tsx | 4 +- react-native-expo/metro.config.js | 2 +- react-native-expo/package.json | 2 +- react-native-expo/scripts/reset-project.js | 42 +- ...1-app-launch-and-seeded-tasks-android.yaml | 10 +- .../01-app-launch-and-seeded-tasks-ios.yaml | 14 +- react-native/App.tsx | 96 ++- .../android/app/src/main/AndroidManifest.xml | 5 + react-native/android/gradle.properties | 9 +- react-native/components/DittoInfo.tsx | 4 +- react-native/components/DittoSync.tsx | 6 +- react-native/components/EditTaskModal.tsx | 4 +- react-native/components/NewTaskModal.tsx | 4 +- react-native/components/TaskDone.tsx | 4 +- react-native/ios/Podfile | 15 + react-native/jest.setup.js | 14 +- react-native/package.json | 2 +- rust-tui/.cargo/config.toml | 10 + rust-tui/Cargo.lock | 15 + rust-tui/Cargo.toml | 2 +- rust-tui/clippy.toml | 11 + rust-tui/src/bin/integration_test.rs | 18 +- rust-tui/src/bin/main.rs | 35 +- rust-tui/src/lib.rs | 7 +- rust-tui/src/term.rs | 5 +- rust-tui/src/tui/mod.rs | 7 +- rust-tui/src/tui/todolist.rs | 26 +- scripts/README.md | 2 - scripts/bump-versions.js | 412 ---------- swift/.swiftlint.yml | 2 +- swift/Tasks.xcodeproj/project.pbxproj | 10 +- swift/buildEnv.sh | 27 +- 125 files changed, 4122 insertions(+), 4180 deletions(-) create mode 100644 flutter_app/test/dql_safety_test.dart create mode 100644 global.json create mode 100644 javascript-tui/prettier.config.cjs delete mode 100644 javascript-tui/prettier.config.js create mode 100644 rust-tui/.cargo/config.toml create mode 100644 rust-tui/clippy.toml delete mode 100644 scripts/README.md delete mode 100755 scripts/bump-versions.js diff --git a/.github/actions/seed-ditto-document/action.yml b/.github/actions/seed-ditto-document/action.yml index cad09b91d..2128d33d5 100644 --- a/.github/actions/seed-ditto-document/action.yml +++ b/.github/actions/seed-ditto-document/action.yml @@ -3,12 +3,16 @@ description: 'Creates a test document in Ditto Cloud with inverted timestamp for author: 'Ditto' inputs: - ditto-api-key: - description: 'Ditto API key for authentication' + ditto-app-id: + description: 'Ditto App ID' required: true - ditto-api-url: - description: 'Ditto API URL (e.g., cloud.ditto.live)' + ditto-playground-token: + description: 'Ditto Playground Token for authentication' required: true + ditto-api-url: + description: 'Ditto Cloud URL Endpoint host (no scheme), e.g. .cloud.ditto.live. Overrides the default {app-id}.cloud.ditto.live host when set.' + required: false + default: '' app-name: description: 'Name of the app/platform being tested (e.g., android-kotlin, swift, etc.)' required: true @@ -31,6 +35,7 @@ runs: id: seed shell: bash run: | + set -euo pipefail # Generate inverted timestamp for top position in list TIMESTAMP=$(date +%s) INVERTED_TIMESTAMP=$((9999999999 - TIMESTAMP)) @@ -45,9 +50,11 @@ runs: echo "📝 Timestamp: ${TIMESTAMP} → Inverted: ${INVERTED_TIMESTAMP}" # Insert document using Ditto API v4 - RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + DITTO_HOST="${{ inputs.ditto-api-url }}" + DITTO_HOST="${DITTO_HOST:-${{ inputs.ditto-app-id }}.cloud.ditto.live}" + RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST \ -H 'Content-type: application/json' \ - -H "Authorization: Bearer ${{ inputs.ditto-api-key }}" \ + -H "Authorization: Bearer ${{ inputs.ditto-playground-token }}" \ -d "{ \"statement\": \"INSERT INTO tasks DOCUMENTS (:newTask) ON ID CONFLICT DO UPDATE\", \"args\": { @@ -59,7 +66,7 @@ runs: } } }" \ - "https://${{ inputs.ditto-api-url }}/api/v4/store/execute") + "https://${DITTO_HOST}/api/v4/store/execute") # Extract HTTP status code and response body (portable version) HTTP_CODE=$(echo "$RESPONSE" | tail -n1) @@ -68,9 +75,9 @@ runs: # Check if insertion was successful if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then echo "✅ Successfully inserted test document" - echo "document_id=${DOC_ID}" >> $GITHUB_OUTPUT - echo "document_title=${DOC_TITLE}" >> $GITHUB_OUTPUT - echo "inverted_timestamp=${INVERTED_TIMESTAMP}" >> $GITHUB_OUTPUT + echo "document_id=${DOC_ID}" >> "$GITHUB_OUTPUT" + echo "document_title=${DOC_TITLE}" >> "$GITHUB_OUTPUT" + echo "inverted_timestamp=${INVERTED_TIMESTAMP}" >> "$GITHUB_OUTPUT" else echo "❌ Failed to insert document. HTTP Status: $HTTP_CODE" echo "Response: $BODY" diff --git a/.github/browserstack-devices.yml b/.github/browserstack-devices.yml index 98b14892f..721a051de 100644 --- a/.github/browserstack-devices.yml +++ b/.github/browserstack-devices.yml @@ -16,95 +16,95 @@ # Android Java QuickStart android-java: devices: - - "Google Pixel 8-14.0" # Latest Android 14 - - "Samsung Galaxy S23-13.0" # Popular flagship, Android 13 - - "Google Pixel 6-12.0" # Android 12 support - - "OnePlus 9-11.0" # Android 11 minimum baseline + - 'Google Pixel 8-14.0' # Latest Android 14 + - 'Samsung Galaxy S23-13.0' # Popular flagship, Android 13 + - 'Google Pixel 6-12.0' # Android 12 support + - 'OnePlus 9-11.0' # Android 11 minimum baseline # Android Kotlin QuickStart android-kotlin: devices: - - "Google Pixel 8-14.0" # Latest Android 14 - - "Samsung Galaxy S23-13.0" # Popular flagship, Android 13 - - "Google Pixel 6-12.0" # Android 12 support - - "OnePlus 9-11.0" # Android 11 minimum baseline + - 'Google Pixel 8-14.0' # Latest Android 14 + - 'Samsung Galaxy S23-13.0' # Popular flagship, Android 13 + - 'Google Pixel 6-12.0' # Android 12 support + - 'OnePlus 9-11.0' # Android 11 minimum baseline # Flutter QuickStart (multi-platform) flutter: android: devices: - - "Google Pixel 7-13.0" # Android 13 baseline - - "Samsung Galaxy S23-13.0" # Popular flagship - - "Google Pixel 6-12.0" # Android 12 support + - 'Google Pixel 7-13.0' # Android 13 baseline + - 'Samsung Galaxy S23-13.0' # Popular flagship + - 'Google Pixel 6-12.0' # Android 12 support ios: devices: - - "iPhone 13-16" # iOS 16 support (min deployment target is 15.6) - - "iPhone 14-16" # iOS 16 current - - "iPhone 12-17" # iOS 17 latest + - 'iPhone 13-16' # iOS 16 support (min deployment target is 15.6) + - 'iPhone 14-16' # iOS 16 current + - 'iPhone 12-17' # iOS 17 latest # Kotlin Multiplatform QuickStart kotlin-multiplatform: android: devices: - - "Google Pixel 8-14.0" # Latest Android 14 - - "Samsung Galaxy S23-13.0" # Popular flagship + - 'Google Pixel 8-14.0' # Latest Android 14 + - 'Samsung Galaxy S23-13.0' # Popular flagship # React Native QuickStart react-native: android: devices: - - "Samsung Galaxy S22-12.0" # Android 12 - - "Google Pixel 7-13.0" # Android 13 + - 'Samsung Galaxy S22-12.0' # Android 12 + - 'Google Pixel 7-13.0' # Android 13 ios: devices: - - "iPhone 15-17.0" # Latest iOS 17 - - "iPhone 14-16.0" # iOS 16 support + - 'iPhone 15-17.0' # Latest iOS 17 + - 'iPhone 14-16.0' # iOS 16 support # React Native Expo QuickStart react-native-expo: android: devices: - - "Samsung Galaxy S22-12.0" # Android 12 - - "Google Pixel 7-13.0" # Android 13 + - 'Samsung Galaxy S22-12.0' # Android 12 + - 'Google Pixel 7-13.0' # Android 13 ios: devices: - - "iPhone 15-17.0" # Latest iOS 17 - - "iPhone 14-16.0" # iOS 16 support + - 'iPhone 15-17.0' # Latest iOS 17 + - 'iPhone 14-16.0' # iOS 16 support # .NET MAUI QuickStart (multi-platform) dotnet-maui: android: devices: - - "Google Pixel 8-14.0" # Latest Android 14 - - "Samsung Galaxy S23-13.0" # Popular flagship + - 'Google Pixel 8-14.0' # Latest Android 14 + - 'Samsung Galaxy S23-13.0' # Popular flagship ios: devices: - - "iPhone 15-17.0" # Latest iOS 17 - - "iPhone 14-16.0" # iOS 16 support + - 'iPhone 15-17.0' # Latest iOS 17 + - 'iPhone 14-16.0' # iOS 16 support # Swift/iOS QuickStart swift: devices: - - "iPhone 15 Pro-17" # Latest flagship with iOS 17 + - 'iPhone 15 Pro-17' # Latest flagship with iOS 17 # Java Spring (web application) # Tests web app in desktop browsers using Selenium WebDriver java-spring: platforms: - - os: "Windows" - osVersion: "11" - browserName: "Chrome" - browserVersion: "latest" # Always test latest Chrome + - os: 'Windows' + osVersion: '11' + browserName: 'Chrome' + browserVersion: 'latest' # Always test latest Chrome # JavaScript Web (React web application) # Tests web app in multiple desktop browsers javascript-web: browsers: - - browser: "Chrome" - browser_version: "120.0" - os: "Windows" - os_version: "11" - - browser: "Firefox" - browser_version: "121.0" - os: "Windows" - os_version: "11" + - browser: 'Chrome' + browser_version: '120.0' + os: 'Windows' + os_version: '11' + - browser: 'Firefox' + browser_version: '121.0' + os: 'Windows' + os_version: '11' diff --git a/LICENSE b/LICENSE index 68d885abb..892015039 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright ©️ 2024 DittoLive, Inc. +Copyright ©️ 2026 DittoLive, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index dc5ab2c41..2ad956f4f 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,16 @@ build and run them. - [Android Kotlin](android-kotlin/README.md) - [Android Java](android-java/README.md) +- [Kotlin Multiplatform](kotlin-multiplatform/README.md) - [Java Server](java-server/README.md) - [C++ TUI](cpp-tui/README.md) - [C# .NET MAUI](dotnet-maui/README.md) - [C# .NET TUI](dotnet-tui/README.md) - [C# .NET Win Forms](dotnet-winforms/README.md) +- [C# .NET Win Forms (.NET Framework 4.8)](dotnet-winforms-net48/README.md) - [Electron](electron/README.md) -- [Flutter](flutter_quickstart/README.md) +- [Flutter](flutter_app/README.md) +- [Go TUI](go-tui/README.md) - [Javascript TUI](javascript-tui/README.md) - [Javascript Web](javascript-web/README.md) - [React Native](react-native/README.md) diff --git a/android-java/gradle/libs.versions.toml b/android-java/gradle/libs.versions.toml index ef0498e31..476869e1c 100644 --- a/android-java/gradle/libs.versions.toml +++ b/android-java/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -ditto = "5.0.0" +ditto = "5.0.2" agp = "8.7.3" kotlin = "2.0.0" coreKtx = "1.10.1" diff --git a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml index e14417738..25055cd2f 100644 --- a/android-kotlin/QuickStartTasks/gradle/libs.versions.toml +++ b/android-kotlin/QuickStartTasks/gradle/libs.versions.toml @@ -15,7 +15,7 @@ appcompat = "1.7.1" datastorePreferences = "1.2.1" koin-bom = "4.2.1" coroutines-tests = "1.10.2" -ditto = "5.0.0" +ditto = "5.0.2" monitor = "1.8.0" json = "20240303" diff --git a/cpp-tui/taskscpp/Makefile b/cpp-tui/taskscpp/Makefile index fc2e95b7e..72c8a7edf 100644 --- a/cpp-tui/taskscpp/Makefile +++ b/cpp-tui/taskscpp/Makefile @@ -2,7 +2,7 @@ BUILD_TYPE ?= Debug # Ditto SDK version and platform detection -DITTO_SDK_VERSION ?= 4.14.3 +DITTO_SDK_VERSION ?= 5.0.2 PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]') ARCH := $(shell uname -m) diff --git a/cpp-tui/taskscpp/src/main.cpp b/cpp-tui/taskscpp/src/main.cpp index ac2dc4dea..65ea72ce2 100644 --- a/cpp-tui/taskscpp/src/main.cpp +++ b/cpp-tui/taskscpp/src/main.cpp @@ -94,11 +94,8 @@ int main(int argc, const char *argv[]) { cxxopts::value(), "APP_ID") ("online-playground-token", "Ditto Online Playground token", cxxopts::value(), "TOKEN") - ("websocket-url", "Ditto WebSocket URL", - cxxopts::value(), "WEBSOCKET_URL") ("auth-url", "Ditto Auth URL", - cxxopts::value(), "AUTH_URL") - ("enable-cloud-sync", "Enable cloud synchronization"); + cxxopts::value(), "AUTH_URL"); options.add_options("Logging") ("q,quiet", "Disable non-logging output") @@ -107,8 +104,6 @@ int main(int argc, const char *argv[]) { ("info", "Info-level logging") ("debug", "Debug-level logging") ("v,verbose","Trace-level logging") - ("log","Log file output path", - cxxopts::value(), "PATH") ("export", "Export-log file path", cxxopts::value(), "PATH") ("ditto-sdk-version", "Print the Ditto SDK version"); @@ -169,10 +164,6 @@ int main(int argc, const char *argv[]) { } set_minimum_log_level(log_level); - if (opt_parse.count("log") > 0) { - set_log_file(opt_parse["log"].as()); - } - if (opt_parse.count("export") > 0) { export_log_path = opt_parse["export"].as(); } @@ -191,15 +182,10 @@ int main(int argc, const char *argv[]) { opt_parse.count("online-playground-token") > 0 ? opt_parse["online-playground-token"].as() : DITTO_PLAYGROUND_TOKEN; - const auto websocket_url = opt_parse.count("websocket-url") > 0 - ? opt_parse["websocket-url"].as() - : DITTO_WEBSOCKET_URL; const auto auth_url = opt_parse.count("auth-url") > 0 ? opt_parse["auth-url"].as() : DITTO_AUTH_URL; - const auto enable_cloud_sync = opt_parse.count("enable-cloud-sync") > 0; - const auto quiet = opt_parse["quiet"].as(); // Set this true if we make modifications and need to allow post-sync time. @@ -207,8 +193,8 @@ int main(int argc, const char *argv[]) { // The peer is destroyed at the end of this scope { - TasksPeer peer(app_id, online_playground_token, websocket_url, auth_url, - enable_cloud_sync, persistence_dir); + TasksPeer peer(app_id, online_playground_token, auth_url, + persistence_dir); peer.insert_initial_tasks(); peer.start_sync(); diff --git a/cpp-tui/taskscpp/src/tasks_log.cpp b/cpp-tui/taskscpp/src/tasks_log.cpp index 71da24384..74ac0823c 100644 --- a/cpp-tui/taskscpp/src/tasks_log.cpp +++ b/cpp-tui/taskscpp/src/tasks_log.cpp @@ -1,37 +1,33 @@ #include "tasks_log.h" -// These functions are all thin wrappers over the ditto::Log() API. Application -// code should call these rather than the ditto::Log() API to make it easy to -// change or extend the logging implementation. +// These functions are all thin wrappers over the ditto::Logger API. +// Application code should call these rather than the ditto::Logger API to make +// it easy to change or extend the logging implementation. -void log_error(const std::string &msg) { ditto::Log::e(msg); } +void log_error(const std::string &msg) { ditto::Logger::e(msg); } -void log_warning(const std::string &msg) { ditto::Log::w(msg); } +void log_warning(const std::string &msg) { ditto::Logger::w(msg); } -void log_info(const std::string &msg) { ditto::Log::i(msg); } +void log_info(const std::string &msg) { ditto::Logger::i(msg); } -void log_debug(const std::string &msg) { ditto::Log::d(msg); } +void log_debug(const std::string &msg) { ditto::Logger::d(msg); } -void log_verbose(const std::string &msg) { ditto::Log::v(msg); } +void log_verbose(const std::string &msg) { ditto::Logger::v(msg); } -bool get_logging_enabled() { return ditto::Log::get_logging_enabled(); } +bool get_logging_enabled() { return ditto::Logger::get_logging_enabled(); } void set_logging_enabled(bool enabled) { - ditto::Log::set_logging_enabled(enabled); + ditto::Logger::set_logging_enabled(enabled); } ditto::LogLevel get_minimum_log_level() { - return ditto::Log::get_minimum_log_level(); + return ditto::Logger::get_minimum_log_level(); } void set_minimum_log_level(ditto::LogLevel level) { - ditto::Log::set_minimum_log_level(level); + ditto::Logger::set_minimum_log_level(level); } -void set_log_file(const std::string &path) { ditto::Log::set_log_file(path); } - -void disable_log_file() { ditto::Log::disable_log_file(); } - void export_log(const std::string &path) { - ditto::Log::export_to_file(path).get(); + ditto::Logger::export_to_file(path).get(); } diff --git a/cpp-tui/taskscpp/src/tasks_log.h b/cpp-tui/taskscpp/src/tasks_log.h index 11d8b9616..1e0781851 100644 --- a/cpp-tui/taskscpp/src/tasks_log.h +++ b/cpp-tui/taskscpp/src/tasks_log.h @@ -17,9 +17,6 @@ void set_logging_enabled(bool enabled); ditto::LogLevel get_minimum_log_level(); void set_minimum_log_level(ditto::LogLevel level); -void set_log_file(const std::string &path); -void disable_log_file(); - void export_log(const std::string &path); #endif // DITTO_QUICKSTART_TASKS_LOG_H diff --git a/cpp-tui/taskscpp/src/tasks_peer.cpp b/cpp-tui/taskscpp/src/tasks_peer.cpp index c155c0591..d569d28a7 100644 --- a/cpp-tui/taskscpp/src/tasks_peer.cpp +++ b/cpp-tui/taskscpp/src/tasks_peer.cpp @@ -44,25 +44,46 @@ static string to_json_string(const ditto::QueryResult &result) { } /// Initialize a Ditto instance. -static shared_ptr -init_ditto(string app_id, string online_playground_token, string websocket_url, - string auth_url, bool enable_cloud_sync, string persistence_dir) { +static shared_ptr init_ditto(string app_id, + string online_playground_token, + string auth_url, + string persistence_dir) { try { - const auto identity = ditto::Identity::OnlinePlayground( - std::move(app_id), std::move(online_playground_token), - enable_cloud_sync, std::move(auth_url)); - - auto ditto = - std::make_shared(identity, std::move(persistence_dir)); - - ditto->update_transport_config( - [websocket_url](ditto::TransportConfig &config) { - config.enable_all_peer_to_peer(); - config.connect.websocket_urls.insert(websocket_url); - }); + auto config = ditto::DittoConfig::default_config() + .set_database_id(std::move(app_id)) + .set_persistence_directory(std::move(persistence_dir)) + .set_server_connect(std::move(auth_url)); + + auto ditto = ditto::Ditto::open(std::move(config)); + + // Log in with the playground token using the built-in development + // authentication provider. The expiration handler re-authenticates + // automatically when the credential is about to expire. + const auto provider = ditto::Authenticator::get_development_provider(); + if (auto auth = ditto->get_auth()) { + auth->set_expiration_handler( + [online_playground_token, provider](ditto::Ditto &d, uint32_t) { + if (auto a = d.get_auth()) { + a->login(online_playground_token, provider, + [](std::unique_ptr, + std::unique_ptr err) { + if (err) { + log_error("Failed to re-authenticate: " + + string(err->what())); + } + }); + } + }); - // Required for compatibility with DQL. - ditto->disable_sync_with_v3(); + auth->login(online_playground_token, provider, + [](std::unique_ptr, + std::unique_ptr err) { + if (err) { + log_error("Failed to authenticate: " + + string(err->what())); + } + }); + } // Disable DQL strict mode // https://docs.ditto.live/dql/strict-mode @@ -92,14 +113,11 @@ class TasksPeer::Impl { // NOLINT(cppcoreguidelines-special-member-functions) } public: - Impl(string app_id, string online_playground_token, string websocket_url, - string auth_url, bool enable_cloud_sync, string persistence_dir) + Impl(string app_id, string online_playground_token, string auth_url, + string persistence_dir) : mtx(new mutex()), ditto(init_ditto(std::move(app_id), std::move(online_playground_token), - std::move(websocket_url), std::move(auth_url), - enable_cloud_sync, // This is required to be set to - // false to use the correct URLs - std::move(persistence_dir))) {} + std::move(auth_url), std::move(persistence_dir))) {} ~Impl() noexcept { try { @@ -116,7 +134,7 @@ class TasksPeer::Impl { // NOLINT(cppcoreguidelines-special-member-functions) return; } - ditto->start_sync(); + ditto->get_sync().start(); tasks_subscription = ditto->get_sync().register_subscription("SELECT * FROM tasks"); } @@ -128,10 +146,10 @@ class TasksPeer::Impl { // NOLINT(cppcoreguidelines-special-member-functions) tasks_subscription->cancel(); tasks_subscription.reset(); - ditto->stop_sync(); + ditto->get_sync().stop(); } - bool is_sync_active() const { return ditto->get_is_sync_active(); } + bool is_sync_active() const { return ditto->get_sync().is_active(); } string add_task(const string &title, bool done) { try { @@ -324,8 +342,7 @@ class TasksPeer::Impl { // NOLINT(cppcoreguidelines-special-member-functions) std::function &)> callback) { try { const auto observer = ditto->get_store().register_observer( - select_tasks_query(), - [callback = std::move(callback)](const ditto::QueryResult &result) { + select_tasks_query(), [callback](const ditto::QueryResult &result) { const auto item_count = result.item_count(); log_debug("Tasks collection updated; count=" + to_string(item_count)); @@ -388,11 +405,9 @@ class TasksPeer::Impl { // NOLINT(cppcoreguidelines-special-member-functions) }; // class TasksPeer::Impl TasksPeer::TasksPeer(string app_id, string online_playground_token, - string websocket_url, string auth_url, - bool enable_cloud_sync, string persistence_dir) + string auth_url, string persistence_dir) : impl(new Impl(std::move(app_id), std::move(online_playground_token), - std::move(websocket_url), std::move(auth_url), - enable_cloud_sync, std::move(persistence_dir))) {} + std::move(auth_url), std::move(persistence_dir))) {} TasksPeer::~TasksPeer() noexcept { try { @@ -449,7 +464,7 @@ string TasksPeer::execute_dql_query(const string &query) { } string TasksPeer::get_ditto_sdk_version() { - return ditto::Ditto::get_sdk_version(); + return ditto::Ditto::get_version(); } void TasksPeer::insert_initial_tasks() { impl->insert_initial_tasks(); } diff --git a/cpp-tui/taskscpp/src/tasks_peer.h b/cpp-tui/taskscpp/src/tasks_peer.h index 3a76307cf..f8897d19b 100644 --- a/cpp-tui/taskscpp/src/tasks_peer.h +++ b/cpp-tui/taskscpp/src/tasks_peer.h @@ -17,8 +17,7 @@ class TasksPeer { /// Construct a new TasksPeer object. TasksPeer(std::string ditto_app_id, std::string ditto_online_playground_token, - std::string ditto_websocket_url, std::string ditto_auth_url, - bool enable_cloud_sync, std::string ditto_persistence_dir); + std::string ditto_auth_url, std::string ditto_persistence_dir); virtual ~TasksPeer() noexcept; diff --git a/cpp-tui/taskscpp/tests/integration_test.cpp b/cpp-tui/taskscpp/tests/integration_test.cpp index a3610d570..82d13d605 100644 --- a/cpp-tui/taskscpp/tests/integration_test.cpp +++ b/cpp-tui/taskscpp/tests/integration_test.cpp @@ -1,7 +1,7 @@ #include "../src/env.h" #include "../src/task.h" -#include "../src/tasks_peer.h" #include "../src/tasks_log.h" +#include "../src/tasks_peer.h" #include #include @@ -12,86 +12,92 @@ using std::cout; using std::endl; +using std::exception; using std::string; -using std::vector; using std::unique_ptr; -using std::exception; -using std::chrono::seconds; +using std::vector; +using std::chrono::duration_cast; using std::chrono::high_resolution_clock; using std::chrono::milliseconds; -using std::chrono::duration_cast; +using std::chrono::seconds; using std::this_thread::sleep_for; /** * Simple test that verifies GitHub-seeded document appears in synced task list */ int main() { - try { - cout << "C++ GitHub Seeded Document Test" << endl; - - // Get the exact document title that GitHub Actions seeded - const auto expected_title_env = getenv("DITTO_CLOUD_TASK_TITLE"); - if (!expected_title_env || string(expected_title_env).empty()) { - cout << "FAIL: Missing DITTO_CLOUD_TASK_TITLE environment variable" << endl; - std::exit(EXIT_FAILURE); - } + try { + cout << "C++ GitHub Seeded Document Test" << endl; + + // Get the exact document title that GitHub Actions seeded + const auto expected_title_env = getenv("DITTO_CLOUD_TASK_TITLE"); + if (!expected_title_env || string(expected_title_env).empty()) { + cout << "FAIL: Missing DITTO_CLOUD_TASK_TITLE environment variable" + << endl; + std::exit(EXIT_FAILURE); + } - string expected_title = string(expected_title_env); - cout << "Looking for seeded document: '" << expected_title << "'" << endl; - - // Initialize TasksPeer and start sync - cout << "Initializing Ditto sync..." << endl; - auto peer = unique_ptr(new TasksPeer( - DITTO_APP_ID, - DITTO_PLAYGROUND_TOKEN, - DITTO_WEBSOCKET_URL, - DITTO_AUTH_URL, - true, // enable_cloud_sync - "/tmp/cpp_integration_test" - )); - - peer->start_sync(); - cout << "Sync started, polling for document..." << endl; - - // Wait for sync and search for the exact document - const auto max_wait_seconds = 30; - const auto poll_interval_ms = 1000; - auto found = false; - - auto start_time = high_resolution_clock::now(); - - while (duration_cast(high_resolution_clock::now() - start_time).count() < max_wait_seconds && !found) { - auto elapsed = duration_cast(high_resolution_clock::now() - start_time).count(); - - vector tasks = peer->get_tasks(); - cout << "Checking " << tasks.size() << " synced tasks at " << elapsed << "s..." << endl; - - for (size_t i = 0; i < tasks.size(); i++) { - const auto& task = tasks[i]; - if (task.title == expected_title) { - cout << "SUCCESS: Found document '" << expected_title << "' at position " << i << endl; - found = true; - break; - } - } - - if (!found) { - sleep_for(milliseconds(poll_interval_ms)); - } + string expected_title = string(expected_title_env); + cout << "Looking for seeded document: '" << expected_title << "'" << endl; + + // Initialize TasksPeer and start sync + cout << "Initializing Ditto sync..." << endl; + auto peer = unique_ptr( + new TasksPeer(DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, + "/tmp/cpp_integration_test")); + + peer->start_sync(); + cout << "Sync started, polling for document..." << endl; + + // Wait for sync and search for the exact document + const auto max_wait_seconds = 30; + const auto poll_interval_ms = 1000; + auto found = false; + + auto start_time = high_resolution_clock::now(); + + while (duration_cast(high_resolution_clock::now() - start_time) + .count() < max_wait_seconds && + !found) { + auto elapsed = + duration_cast(high_resolution_clock::now() - start_time) + .count(); + + vector tasks = peer->get_tasks(); + cout << "Checking " << tasks.size() << " synced tasks at " << elapsed + << "s..." << endl; + + for (size_t i = 0; i < tasks.size(); i++) { + const auto &task = tasks[i]; + if (task.title == expected_title) { + cout << "SUCCESS: Found document '" << expected_title + << "' at position " << i << endl; + found = true; + break; } + } - auto final_elapsed = duration_cast(high_resolution_clock::now() - start_time).count(); - - if (found) { - cout << "PASS: GitHub Actions → Ditto Cloud → C++ SDK sync verified in " << final_elapsed << "s" << endl; - return 0; - } else { - cout << "FAIL: Document '" << expected_title << "' not found after " << final_elapsed << "s" << endl; - std::exit(EXIT_FAILURE); - } + if (!found) { + sleep_for(milliseconds(poll_interval_ms)); + } + } - } catch (const exception& e) { - cout << "FAIL: Test exception: " << e.what() << endl; - std::exit(EXIT_FAILURE); + auto final_elapsed = + duration_cast(high_resolution_clock::now() - start_time) + .count(); + + if (found) { + cout << "PASS: GitHub Actions → Ditto Cloud → C++ SDK sync verified in " + << final_elapsed << "s" << endl; + return 0; + } else { + cout << "FAIL: Document '" << expected_title << "' not found after " + << final_elapsed << "s" << endl; + std::exit(EXIT_FAILURE); } + + } catch (const exception &e) { + cout << "FAIL: Test exception: " << e.what() << endl; + std::exit(EXIT_FAILURE); + } } \ No newline at end of file diff --git a/cpp-tui/taskscpp/tests/run_tests.sh b/cpp-tui/taskscpp/tests/run_tests.sh index 201251448..d9809264d 100755 --- a/cpp-tui/taskscpp/tests/run_tests.sh +++ b/cpp-tui/taskscpp/tests/run_tests.sh @@ -59,4 +59,4 @@ echo "" echo "🎉 Integration Tests Completed Successfully!" echo "==========================================" echo "✅ Integration tests: PASSED" -echo "🎯 C++ Ditto TUI application validated!" \ No newline at end of file +echo "🎯 C++ Ditto TUI application validated!" diff --git a/dotnet-maui/DittoMauiTasksApp/DittoMauiTasksApp.csproj b/dotnet-maui/DittoMauiTasksApp/DittoMauiTasksApp.csproj index 5619b566a..7757536dc 100644 --- a/dotnet-maui/DittoMauiTasksApp/DittoMauiTasksApp.csproj +++ b/dotnet-maui/DittoMauiTasksApp/DittoMauiTasksApp.csproj @@ -1,67 +1,67 @@ - - - - net10.0-android;net10.0-ios;net10.0-maccatalyst - net10.0-windows10.0.19041.0;net10.0-android;net10.0-ios - net10.0-android - Exe - DittoMauiTasksApp - true - true - enable - - - DittoMauiTasksApp - - - live.ditto.quickstart.mauitasksapp - c8b2d96a-f86c-493c-9466-4a893592fbf9 - - 1.0 - 1 - - 15.0 - 24.0 - 15.0 - 10.0.17763.0 - - - - - QS Tasks - - - - - partial - true - all - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + net10.0-windows10.0.19041.0;net10.0-android;net10.0-ios + net10.0-android + Exe + DittoMauiTasksApp + true + true + enable + + + DittoMauiTasksApp + + + live.ditto.quickstart.mauitasksapp + c8b2d96a-f86c-493c-9466-4a893592fbf9 + + 1.0 + 1 + + 15.0 + 24.0 + 15.0 + 10.0.17763.0 + + + + + QS Tasks + + + + + partial + true + all + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs b/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs index e316382a6..b45cb5587 100644 --- a/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs +++ b/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs @@ -1,131 +1,131 @@ -using System.Reflection; - -using Microsoft.Extensions.Logging; - -using DittoMauiTasksApp.Utils; -using DittoMauiTasksApp.ViewModels; -using DittoSDK; -using DittoSDK.Auth; - -namespace DittoMauiTasksApp; - -public static class MauiProgram -{ - public static string AppId { get; private set; } = ""; - public static string PlaygroundToken { get; private set; } = ""; - - public static MauiApp CreateMauiApp() - { - var builder = MauiApp.CreateBuilder(); - builder - .UseMauiApp() - .ConfigureFonts(fonts => - { - fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); - fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); - }); - -#if DEBUG - builder.Logging.SetMinimumLevel(LogLevel.Debug); - builder.Logging.AddDebug(); -#endif - builder.Services.AddSingleton(SetupDitto()); - builder.Services.AddSingleton(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - - return builder.Build(); - } - - private static Ditto SetupDitto() - { - var envVars = LoadEnvVariables(); - AppId = envVars["DITTO_APP_ID"]; - PlaygroundToken = envVars["DITTO_PLAYGROUND_TOKEN"]; - var authUrl = envVars["DITTO_AUTH_URL"]; - - // New Initialization code - https://docs.ditto.live/sdk/latest/ditto-config - var dittoConfig = new DittoConfig( +using System.Reflection; + +using Microsoft.Extensions.Logging; + +using DittoMauiTasksApp.Utils; +using DittoMauiTasksApp.ViewModels; +using DittoSDK; +using DittoSDK.Auth; + +namespace DittoMauiTasksApp; + +public static class MauiProgram +{ + public static string AppId { get; private set; } = ""; + public static string PlaygroundToken { get; private set; } = ""; + + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + +#if DEBUG + builder.Logging.SetMinimumLevel(LogLevel.Debug); + builder.Logging.AddDebug(); +#endif + builder.Services.AddSingleton(SetupDitto()); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + return builder.Build(); + } + + private static Ditto SetupDitto() + { + var envVars = LoadEnvVariables(); + AppId = envVars["DITTO_APP_ID"]; + PlaygroundToken = envVars["DITTO_PLAYGROUND_TOKEN"]; + var authUrl = envVars["DITTO_AUTH_URL"]; + + // New Initialization code - https://docs.ditto.live/sdk/latest/ditto-config + var dittoConfig = new DittoConfig( AppId, - new DittoConfigConnect.Server( - new Uri(authUrl) + new DittoConfigConnect.Server( + new Uri(authUrl) ), - Path.Combine(FileSystem.Current.AppDataDirectory, "ditto") - ); - + Path.Combine(FileSystem.Current.AppDataDirectory, "ditto") + ); + var ditto = Ditto.Open(dittoConfig); // Set up authentication expiration handler (required for server connections) - ditto.Auth.ExpirationHandler = async (dittoAuth, secondsRemaining) => - { - // Authenticate when token is expiring - try - { + ditto.Auth.ExpirationHandler = async (dittoAuth, secondsRemaining) => + { + // Authenticate when token is expiring + try + { await dittoAuth.Auth.LoginAsync( // Your development token, replace with your actual token PlaygroundToken, // Use DittoAuthenticationProvider.Development for playground, or your actual provider - DittoAuthenticationProvider.Development - ); - Console.WriteLine("Authentication successful"); - } - catch (Exception error) - { - Console.WriteLine($"Authentication failed: {error}"); - } + DittoAuthenticationProvider.Development + ); + Console.WriteLine("Authentication successful"); + } + catch (Exception error) + { + Console.WriteLine($"Authentication failed: {error}"); + } }; - return ditto; - } - - /// - /// Load environment variables from the embedded .env resource file. - /// - static Dictionary LoadEnvVariables() - { - var envVars = new Dictionary(); - var assembly = Assembly.GetExecutingAssembly(); - string resourceName = "DittoMauiTasksApp..env"; - - using (Stream stream = assembly.GetManifestResourceStream(resourceName)) - { - if (stream == null) - { - var availableResources = string.Join(Environment.NewLine, assembly.GetManifestResourceNames()); - throw new InvalidOperationException($"Resource '{resourceName}' not found. Available resources: {availableResources}"); - } - - using (var reader = new StreamReader(stream)) - { - string line; - while ((line = reader.ReadLine()) != null) - { - line = line.Trim(); - - if (string.IsNullOrEmpty(line) || line.StartsWith("#")) - { - continue; - } - - int separatorIndex = line.IndexOf('='); - if (separatorIndex < 0) - { - continue; - } - - string key = line.Substring(0, separatorIndex).Trim(); - string value = line.Substring(separatorIndex + 1).Trim(); - - if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) - { - value = value.Substring(1, value.Length - 2); - } - - envVars[key] = value; - } - } - } - - return envVars; - } -} + return ditto; + } + + /// + /// Load environment variables from the embedded .env resource file. + /// + static Dictionary LoadEnvVariables() + { + var envVars = new Dictionary(); + var assembly = Assembly.GetExecutingAssembly(); + string resourceName = "DittoMauiTasksApp..env"; + + using (Stream stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + var availableResources = string.Join(Environment.NewLine, assembly.GetManifestResourceNames()); + throw new InvalidOperationException($"Resource '{resourceName}' not found. Available resources: {availableResources}"); + } + + using (var reader = new StreamReader(stream)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + line = line.Trim(); + + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) + { + continue; + } + + int separatorIndex = line.IndexOf('='); + if (separatorIndex < 0) + { + continue; + } + + string key = line.Substring(0, separatorIndex).Trim(); + string value = line.Substring(separatorIndex + 1).Trim(); + + if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) + { + value = value.Substring(1, value.Length - 2); + } + + envVars[key] = value; + } + } + } + + return envVars; + } +} diff --git a/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/AppDelegate.cs b/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/AppDelegate.cs index b2dfc7656..47b449a56 100644 --- a/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/AppDelegate.cs +++ b/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/AppDelegate.cs @@ -6,4 +6,4 @@ namespace DittoMauiTasksApp; public class AppDelegate : MauiUIApplicationDelegate { protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); -} \ No newline at end of file +} diff --git a/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/Program.cs b/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/Program.cs index c252a7fb9..544b0218f 100644 --- a/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/Program.cs +++ b/dotnet-maui/DittoMauiTasksApp/Platforms/MacCatalyst/Program.cs @@ -12,4 +12,4 @@ static void Main(string[] args) // you can specify it here. UIApplication.Main(args, null, typeof(AppDelegate)); } -} \ No newline at end of file +} diff --git a/dotnet-maui/DittoMauiTasksApp/Utils/PopupService.cs b/dotnet-maui/DittoMauiTasksApp/Utils/PopupService.cs index f158f5b22..aa46f2e7a 100644 --- a/dotnet-maui/DittoMauiTasksApp/Utils/PopupService.cs +++ b/dotnet-maui/DittoMauiTasksApp/Utils/PopupService.cs @@ -1,14 +1,14 @@ -using System; - -namespace DittoMauiTasksApp.Utils -{ - public class PopupService : IPopupService - { - public Task DisplayPromptAsync(string title, string message, string placeholder, string initialValue = "") - { - Page page = Application.Current?.Windows[0].Page; - return page.DisplayPromptAsync(title, message, placeholder: placeholder, initialValue: initialValue); - } - } -} - +using System; + +namespace DittoMauiTasksApp.Utils +{ + public class PopupService : IPopupService + { + public Task DisplayPromptAsync(string title, string message, string placeholder, string initialValue = "") + { + Page page = Application.Current?.Windows[0].Page; + return page.DisplayPromptAsync(title, message, placeholder: placeholder, initialValue: initialValue); + } + } +} + diff --git a/dotnet-maui/DittoMauiTasksApp/ViewModels/TasksPageviewModel.cs b/dotnet-maui/DittoMauiTasksApp/ViewModels/TasksPageviewModel.cs index 066ff0113..a77b5959f 100644 --- a/dotnet-maui/DittoMauiTasksApp/ViewModels/TasksPageviewModel.cs +++ b/dotnet-maui/DittoMauiTasksApp/ViewModels/TasksPageviewModel.cs @@ -1,370 +1,370 @@ - -using System.Collections.ObjectModel; -using System.Text.Json; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DittoMauiTasksApp.Utils; -using DittoSDK; -using DittoSDK.Sync; -using Microsoft.Extensions.Logging; - -namespace DittoMauiTasksApp.ViewModels -{ - public partial class TasksPageviewModel : ObservableObject - { - private const string SelectQuery = "SELECT * FROM tasks WHERE NOT deleted"; - - private readonly Ditto ditto; - private readonly IPopupService popupService; - private readonly ILogger logger; - private DittoSyncSubscription syncSubscription; - - public string AppIdText { get; } = $"App ID: {MauiProgram.AppId}"; - public string TokenText { get; } = $"Token: {MauiProgram.PlaygroundToken}"; - - [ObservableProperty] - ObservableCollection tasks; - - [ObservableProperty] - private bool isSyncEnabled = true; - - public TasksPageviewModel( - Ditto ditto, IPopupService popupService, ILogger logger) - { - this.ditto = ditto; - this.popupService = popupService; - this.logger = logger; -#if WINDOWS - try - { - Task.Run(async () => - { - await InsertInitialTasks(); - ObserveDittoTasksCollection(); - StartSync(); - - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Unable to start Ditto sync: {e.Message}"); - } -#else - - DittoSyncPermissions.RequestPermissionsAsync().ContinueWith(async t => - { - try - { - await InsertInitialTasks(); - ObserveDittoTasksCollection(); - StartSync(); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Unable to start Ditto sync: {e.Message}"); - } - }); -#endif - } - private async Task InsertInitialTasks() - { - try - { - var initialTasks = new List> - { - new Dictionary - { - {"_id", "50191411-4C46-4940-8B72-5F8017A04FA7"}, - {"title", "Buy groceries"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "6DA283DA-8CFE-4526-A6FA-D385089364E5"}, - {"title", "Clean the kitchen"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "5303DDF8-0E72-4FEB-9E82-4B007E5797F0"}, - {"title", "Schedule dentist appointment"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "38411F1B-6B49-4346-90C3-0B16CE97E174"}, - {"title", "Pay bills"}, - {"done", false}, - {"deleted", false} - } - }; - - var insertCommand = "INSERT INTO tasks INITIAL DOCUMENTS (:task)"; - foreach (var task in initialTasks) - { - await ditto.Store.ExecuteAsync(insertCommand, new Dictionary() - { - { "task", task } - }); - } - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error adding initial tasks: {e.Message}"); - } - } - - [RelayCommand] - private async Task AddTaskAsync() - { - try - { - var title = await popupService.DisplayPromptAsync( - "Add Task", "Add a new task:", "Task title"); - - if (string.IsNullOrWhiteSpace(title)) - { - // nothing was entered - return; - } - title.Trim(); - - var doc = new Dictionary - { - {"title", title}, - {"done", false}, - {"deleted", false } - }; - var insertCommand = "INSERT INTO tasks DOCUMENTS (:doc)"; - await ditto.Store.ExecuteAsync(insertCommand, new Dictionary() - { - { "doc", doc } - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error adding task: {e.Message}"); - } - } - - [RelayCommand] - private async Task EditTaskAsync(DittoTask task) - { - try - { - var newTitle = await popupService.DisplayPromptAsync( - "Edit Task", "Change task title:", "Task title", - initialValue: task.Title); - - if (string.IsNullOrWhiteSpace(newTitle)) - { - // nothing was entered - return; - } - newTitle.Trim(); - - var updateQuery = "UPDATE tasks " + - "SET title = :title " + - "WHERE _id = :id"; - await ditto.Store.ExecuteAsync(updateQuery, new Dictionary() - { - {"title", newTitle}, - {"id", task.Id} - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error editing task: {e.Message}"); - } - } - - [RelayCommand] - private void DeleteTask(DittoTask task) - { - try - { - var updateQuery = "UPDATE tasks " + - "SET deleted = true " + - "WHERE _id = :id"; - ditto.Store.ExecuteAsync(updateQuery, new Dictionary() - { - { "id", task.Id } - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error deleting task: {e.Message}"); - } - } - - [RelayCommand] - private Task UpdateTaskDoneAsync(DittoTask task) - { - try - { - if (task == null) - { - logger.LogWarning("TasksPageviewModel: UpdateTaskDoneAsync called with null task"); - return Task.CompletedTask; - } - - var taskId = task.Id; - var newDoneState = task.Done; - - // Fire-and-forget the Ditto update to avoid blocking the UI - // thread while handling a checkbox change - _ = Task.Run(async () => - { - try - { - // Update the task done state only if it has changed, to - // avoid unnecessary calls to the store observer callback. - var updateQuery = "UPDATE tasks " + - "SET done = :newDoneState " + - "WHERE _id = :id AND done != :newDoneState"; - var result = await ditto.Store.ExecuteAsync(updateQuery, new Dictionary - { - { "newDoneState", newDoneState }, - { "id", taskId } - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error updating task done state for {taskId}: {e.Message}"); - } - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error updating task done state: {e.Message}"); - } - return Task.CompletedTask; - } - - private void ObserveDittoTasksCollection() - { - // Register observer, which runs against the local database on this peer - // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers - ditto.Store.RegisterObserver(SelectQuery, async (queryResult) => - { - try - { - var newTasks = queryResult.Items.Select(d => - JsonSerializer.Deserialize(d.JsonString()) - ).ToList(); - - MainThread.BeginInvokeOnMainThread(() => - { - try - { - if (Tasks == null) - { - Tasks = new ObservableCollection(newTasks); - } - else - { - UpdateTasks(newTasks); - } - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error: Unable to update list view model: {e.Message}"); - } - }); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error: Unable to process tasks collection change: {e.Message}"); - } - }); - } - - private void UpdateTasks(List newTasks) - { - var oldCount = Tasks.Count; - var newCount = newTasks.Count; - var minCount = Math.Min(oldCount, newCount); - - for (var i = 0; i < minCount; i++) - { - var existingTask = Tasks[i]; - var newTask = newTasks[i]; - existingTask.Id = newTask.Id; - existingTask.Title = newTask.Title; - existingTask.Done = newTask.Done; - existingTask.Deleted = newTask.Deleted; - } - - if (oldCount < newCount) - { - for (var i = oldCount; i < newCount; i++) - { - Tasks.Add(newTasks[i]); - } - } - else if (oldCount > newCount) - { - for (var i = oldCount - 1; i >= newCount; i--) - { - Tasks.RemoveAt(i); - } - } - } - - partial void OnIsSyncEnabledChanged(bool value) - { - if (value) - { - StartSync(); - } - else - { - StopSync(); - } - } - - private void StartSync() - { - try - { - ditto.Sync.Start(); - - // Register a subscription, which determines what data syncs to this peer - // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions - syncSubscription = ditto.Sync.RegisterSubscription(SelectQuery); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error starting Ditto sync: {e.Message}"); - } - } - - private void StopSync() - { - if (syncSubscription != null) - { - try - { - syncSubscription.Cancel(); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error cancelling sync subscription: {e.Message}"); - } - syncSubscription = null; - } - - try - { - ditto.Sync.Stop(); - } - catch (Exception e) - { - logger.LogError($"TasksPageviewModel: Error stopping Ditto sync: {e.Message}"); - } - } - } -} + +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DittoMauiTasksApp.Utils; +using DittoSDK; +using DittoSDK.Sync; +using Microsoft.Extensions.Logging; + +namespace DittoMauiTasksApp.ViewModels +{ + public partial class TasksPageviewModel : ObservableObject + { + private const string SelectQuery = "SELECT * FROM tasks WHERE NOT deleted"; + + private readonly Ditto ditto; + private readonly IPopupService popupService; + private readonly ILogger logger; + private DittoSyncSubscription syncSubscription; + + public string AppIdText { get; } = $"App ID: {MauiProgram.AppId}"; + public string TokenText { get; } = $"Token: {MauiProgram.PlaygroundToken}"; + + [ObservableProperty] + ObservableCollection tasks; + + [ObservableProperty] + private bool isSyncEnabled = true; + + public TasksPageviewModel( + Ditto ditto, IPopupService popupService, ILogger logger) + { + this.ditto = ditto; + this.popupService = popupService; + this.logger = logger; +#if WINDOWS + try + { + Task.Run(async () => + { + await InsertInitialTasks(); + ObserveDittoTasksCollection(); + StartSync(); + + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Unable to start Ditto sync: {e.Message}"); + } +#else + + DittoSyncPermissions.RequestPermissionsAsync().ContinueWith(async t => + { + try + { + await InsertInitialTasks(); + ObserveDittoTasksCollection(); + StartSync(); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Unable to start Ditto sync: {e.Message}"); + } + }); +#endif + } + private async Task InsertInitialTasks() + { + try + { + var initialTasks = new List> + { + new Dictionary + { + {"_id", "50191411-4C46-4940-8B72-5F8017A04FA7"}, + {"title", "Buy groceries"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "6DA283DA-8CFE-4526-A6FA-D385089364E5"}, + {"title", "Clean the kitchen"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "5303DDF8-0E72-4FEB-9E82-4B007E5797F0"}, + {"title", "Schedule dentist appointment"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "38411F1B-6B49-4346-90C3-0B16CE97E174"}, + {"title", "Pay bills"}, + {"done", false}, + {"deleted", false} + } + }; + + var insertCommand = "INSERT INTO tasks INITIAL DOCUMENTS (:task)"; + foreach (var task in initialTasks) + { + await ditto.Store.ExecuteAsync(insertCommand, new Dictionary() + { + { "task", task } + }); + } + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error adding initial tasks: {e.Message}"); + } + } + + [RelayCommand] + private async Task AddTaskAsync() + { + try + { + var title = await popupService.DisplayPromptAsync( + "Add Task", "Add a new task:", "Task title"); + + if (string.IsNullOrWhiteSpace(title)) + { + // nothing was entered + return; + } + title.Trim(); + + var doc = new Dictionary + { + {"title", title}, + {"done", false}, + {"deleted", false } + }; + var insertCommand = "INSERT INTO tasks DOCUMENTS (:doc)"; + await ditto.Store.ExecuteAsync(insertCommand, new Dictionary() + { + { "doc", doc } + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error adding task: {e.Message}"); + } + } + + [RelayCommand] + private async Task EditTaskAsync(DittoTask task) + { + try + { + var newTitle = await popupService.DisplayPromptAsync( + "Edit Task", "Change task title:", "Task title", + initialValue: task.Title); + + if (string.IsNullOrWhiteSpace(newTitle)) + { + // nothing was entered + return; + } + newTitle.Trim(); + + var updateQuery = "UPDATE tasks " + + "SET title = :title " + + "WHERE _id = :id"; + await ditto.Store.ExecuteAsync(updateQuery, new Dictionary() + { + {"title", newTitle}, + {"id", task.Id} + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error editing task: {e.Message}"); + } + } + + [RelayCommand] + private void DeleteTask(DittoTask task) + { + try + { + var updateQuery = "UPDATE tasks " + + "SET deleted = true " + + "WHERE _id = :id"; + ditto.Store.ExecuteAsync(updateQuery, new Dictionary() + { + { "id", task.Id } + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error deleting task: {e.Message}"); + } + } + + [RelayCommand] + private Task UpdateTaskDoneAsync(DittoTask task) + { + try + { + if (task == null) + { + logger.LogWarning("TasksPageviewModel: UpdateTaskDoneAsync called with null task"); + return Task.CompletedTask; + } + + var taskId = task.Id; + var newDoneState = task.Done; + + // Fire-and-forget the Ditto update to avoid blocking the UI + // thread while handling a checkbox change + _ = Task.Run(async () => + { + try + { + // Update the task done state only if it has changed, to + // avoid unnecessary calls to the store observer callback. + var updateQuery = "UPDATE tasks " + + "SET done = :newDoneState " + + "WHERE _id = :id AND done != :newDoneState"; + var result = await ditto.Store.ExecuteAsync(updateQuery, new Dictionary + { + { "newDoneState", newDoneState }, + { "id", taskId } + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error updating task done state for {taskId}: {e.Message}"); + } + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error updating task done state: {e.Message}"); + } + return Task.CompletedTask; + } + + private void ObserveDittoTasksCollection() + { + // Register observer, which runs against the local database on this peer + // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers + ditto.Store.RegisterObserver(SelectQuery, async (queryResult) => + { + try + { + var newTasks = queryResult.Items.Select(d => + JsonSerializer.Deserialize(d.JsonString()) + ).ToList(); + + MainThread.BeginInvokeOnMainThread(() => + { + try + { + if (Tasks == null) + { + Tasks = new ObservableCollection(newTasks); + } + else + { + UpdateTasks(newTasks); + } + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error: Unable to update list view model: {e.Message}"); + } + }); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error: Unable to process tasks collection change: {e.Message}"); + } + }); + } + + private void UpdateTasks(List newTasks) + { + var oldCount = Tasks.Count; + var newCount = newTasks.Count; + var minCount = Math.Min(oldCount, newCount); + + for (var i = 0; i < minCount; i++) + { + var existingTask = Tasks[i]; + var newTask = newTasks[i]; + existingTask.Id = newTask.Id; + existingTask.Title = newTask.Title; + existingTask.Done = newTask.Done; + existingTask.Deleted = newTask.Deleted; + } + + if (oldCount < newCount) + { + for (var i = oldCount; i < newCount; i++) + { + Tasks.Add(newTasks[i]); + } + } + else if (oldCount > newCount) + { + for (var i = oldCount - 1; i >= newCount; i--) + { + Tasks.RemoveAt(i); + } + } + } + + partial void OnIsSyncEnabledChanged(bool value) + { + if (value) + { + StartSync(); + } + else + { + StopSync(); + } + } + + private void StartSync() + { + try + { + ditto.Sync.Start(); + + // Register a subscription, which determines what data syncs to this peer + // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions + syncSubscription = ditto.Sync.RegisterSubscription(SelectQuery); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error starting Ditto sync: {e.Message}"); + } + } + + private void StopSync() + { + if (syncSubscription != null) + { + try + { + syncSubscription.Cancel(); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error cancelling sync subscription: {e.Message}"); + } + syncSubscription = null; + } + + try + { + ditto.Sync.Stop(); + } + catch (Exception e) + { + logger.LogError($"TasksPageviewModel: Error stopping Ditto sync: {e.Message}"); + } + } + } +} diff --git a/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml b/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml index c68c9bdce..cfc358a98 100644 --- a/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml +++ b/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml @@ -1,93 +1,93 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml.cs b/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml.cs index 32efb54f0..6ea861919 100644 --- a/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml.cs +++ b/dotnet-maui/DittoMauiTasksApp/Views/TasksPage.xaml.cs @@ -1,31 +1,31 @@ -using DittoMauiTasksApp.ViewModels; - -namespace DittoMauiTasksApp; - -public partial class TasksPage : ContentPage -{ - public TasksPage(TasksPageviewModel viewModel) - { - InitializeComponent(); - - BindingContext = viewModel; - } - - // Event handler invoked when a checkbox on the page is checked or unchecked. - private void OnCheckBoxCheckedChanged(object sender, CheckedChangedEventArgs e) - { - if (sender is CheckBox checkBox && checkBox.BindingContext is DittoTask task) - { - if (!checkBox.IsVisible || !checkBox.IsEnabled) - { - return; - } - - var viewModel = BindingContext as TasksPageviewModel; - if (viewModel?.UpdateTaskDoneCommand.CanExecute(task) == true) - { - viewModel.UpdateTaskDoneCommand.Execute(task); - } - } - } -} +using DittoMauiTasksApp.ViewModels; + +namespace DittoMauiTasksApp; + +public partial class TasksPage : ContentPage +{ + public TasksPage(TasksPageviewModel viewModel) + { + InitializeComponent(); + + BindingContext = viewModel; + } + + // Event handler invoked when a checkbox on the page is checked or unchecked. + private void OnCheckBoxCheckedChanged(object sender, CheckedChangedEventArgs e) + { + if (sender is CheckBox checkBox && checkBox.BindingContext is DittoTask task) + { + if (!checkBox.IsVisible || !checkBox.IsEnabled) + { + return; + } + + var viewModel = BindingContext as TasksPageviewModel; + if (viewModel?.UpdateTaskDoneCommand.CanExecute(task) == true) + { + viewModel.UpdateTaskDoneCommand.Execute(task); + } + } + } +} diff --git a/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs b/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs index f8f6b886a..6eb6eaebe 100644 --- a/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs +++ b/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs @@ -1,86 +1,86 @@ -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Appium.Android; -using UITests.Shared; - -namespace UITests.Android; - -public class AndroidTaskSearchTests : TaskSearchTests -{ - protected override AppiumDriver CreateDriver() - { - var options = new AppiumOptions(); - - // Check if running on BrowserStack - var browserstackUsername = Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME"); - var browserstackAccessKey = Environment.GetEnvironmentVariable("BROWSERSTACK_ACCESS_KEY"); - var browserstackApp = Environment.GetEnvironmentVariable("BROWSERSTACK_APP_ID"); - - if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) - { - // Load device config from environment (set by workflow from browserstack-devices.json) - var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); - if (string.IsNullOrEmpty(deviceString)) - { - throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); - } - var deviceParts = deviceString.Split('-'); - var deviceName = deviceParts[0]; - var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'Google Pixel 7-13.0')"); - - // BrowserStack capabilities - options.PlatformName = "Android"; - options.AutomationName = "UiAutomator2"; - options.DeviceName = deviceName; - options.PlatformVersion = platformVersion; - options.App = browserstackApp ?? GetAppPath(); - options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); - options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); - options.AddAdditionalAppiumOption("name", "Android Task Search Tests"); - - var uri = new Uri($"https://{browserstackUsername}:{browserstackAccessKey}@hub-cloud.browserstack.com/wd/hub"); - return new AndroidDriver(uri, options); - } - else - { - // Local testing capabilities - options.PlatformName = "Android"; - options.AutomationName = "UIAutomator2"; - options.App = GetAppPath(); - - // Optional capabilities for local testing - options.AddAdditionalAppiumOption("appWaitActivity", "crc647fcdc6dfabca042e.MainActivity"); - options.AddAdditionalAppiumOption("newCommandTimeout", 300); - options.AddAdditionalAppiumOption("autoGrantPermissions", true); - - // Optional: target a specific device when multiple are connected (e.g. APPIUM_UDID=emulator-5554) - var explicitUdid = Environment.GetEnvironmentVariable("APPIUM_UDID"); - if (!string.IsNullOrEmpty(explicitUdid)) - { - options.AddAdditionalAppiumOption("udid", explicitUdid); - } - - return new AndroidDriver(new Uri("http://127.0.0.1:4723"), options); - } - } - - private string GetAppPath() - { - var projectRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); - var apkFileName = "live.ditto.quickstart.mauitasksapp-Signed.apk"; - - // Look for APK in release directory first (what we just built) - var releasePath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Release", "net10.0-android", apkFileName); - if (File.Exists(releasePath)) - { - return releasePath; - } - - var debugPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-android", apkFileName); - if (File.Exists(debugPath)) - { - return debugPath; - } - - throw new FileNotFoundException($"APK not found. Checked:\n- {debugPath}\n- {releasePath}"); - } -} \ No newline at end of file +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Android; +using UITests.Shared; + +namespace UITests.Android; + +public class AndroidTaskSearchTests : TaskSearchTests +{ + protected override AppiumDriver CreateDriver() + { + var options = new AppiumOptions(); + + // Check if running on BrowserStack + var browserstackUsername = Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME"); + var browserstackAccessKey = Environment.GetEnvironmentVariable("BROWSERSTACK_ACCESS_KEY"); + var browserstackApp = Environment.GetEnvironmentVariable("BROWSERSTACK_APP_ID"); + + if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) + { + // Load device config from environment (set by workflow from browserstack-devices.json) + var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); + if (string.IsNullOrEmpty(deviceString)) + { + throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); + } + var deviceParts = deviceString.Split('-'); + var deviceName = deviceParts[0]; + var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'Google Pixel 7-13.0')"); + + // BrowserStack capabilities + options.PlatformName = "Android"; + options.AutomationName = "UiAutomator2"; + options.DeviceName = deviceName; + options.PlatformVersion = platformVersion; + options.App = browserstackApp ?? GetAppPath(); + options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); + options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); + options.AddAdditionalAppiumOption("name", "Android Task Search Tests"); + + var uri = new Uri($"https://{browserstackUsername}:{browserstackAccessKey}@hub-cloud.browserstack.com/wd/hub"); + return new AndroidDriver(uri, options); + } + else + { + // Local testing capabilities + options.PlatformName = "Android"; + options.AutomationName = "UIAutomator2"; + options.App = GetAppPath(); + + // Optional capabilities for local testing + options.AddAdditionalAppiumOption("appWaitActivity", "crc647fcdc6dfabca042e.MainActivity"); + options.AddAdditionalAppiumOption("newCommandTimeout", 300); + options.AddAdditionalAppiumOption("autoGrantPermissions", true); + + // Optional: target a specific device when multiple are connected (e.g. APPIUM_UDID=emulator-5554) + var explicitUdid = Environment.GetEnvironmentVariable("APPIUM_UDID"); + if (!string.IsNullOrEmpty(explicitUdid)) + { + options.AddAdditionalAppiumOption("udid", explicitUdid); + } + + return new AndroidDriver(new Uri("http://127.0.0.1:4723"), options); + } + } + + private string GetAppPath() + { + var projectRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var apkFileName = "live.ditto.quickstart.mauitasksapp-Signed.apk"; + + // Look for APK in release directory first (what we just built) + var releasePath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Release", "net10.0-android", apkFileName); + if (File.Exists(releasePath)) + { + return releasePath; + } + + var debugPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-android", apkFileName); + if (File.Exists(debugPath)) + { + return debugPath; + } + + throw new FileNotFoundException($"APK not found. Checked:\n- {debugPath}\n- {releasePath}"); + } +} diff --git a/dotnet-maui/UITests.Android/Program.cs b/dotnet-maui/UITests.Android/Program.cs index a70d634d8..a8419ba28 100644 --- a/dotnet-maui/UITests.Android/Program.cs +++ b/dotnet-maui/UITests.Android/Program.cs @@ -33,4 +33,4 @@ { // Ignore teardown errors } -} \ No newline at end of file +} diff --git a/dotnet-maui/UITests.Shared/BaseTest.cs b/dotnet-maui/UITests.Shared/BaseTest.cs index 5d9bf849d..98f1be580 100644 --- a/dotnet-maui/UITests.Shared/BaseTest.cs +++ b/dotnet-maui/UITests.Shared/BaseTest.cs @@ -1,123 +1,123 @@ -using NUnit.Framework; -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Appium.Android; -using OpenQA.Selenium.Appium.iOS; -using System.Reflection; - -namespace UITests.Shared; - -public abstract class BaseTest -{ - protected AppiumDriver App { get; set; } = null!; - private static bool _assemblyResolveHooked = false; - - protected abstract AppiumDriver CreateDriver(); - - [OneTimeSetUp] - public void OneTimeSetUp() - { - if (!_assemblyResolveHooked) - { - AppDomain.CurrentDomain.AssemblyResolve += ResolveAppiumAssembly; - _assemblyResolveHooked = true; - } - } - - private static Assembly? ResolveAppiumAssembly(object? sender, ResolveEventArgs args) - { - var assemblyName = new AssemblyName(args.Name); - - if (assemblyName.Name == "Appium.Net") - { - var nugetPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".nuget", "packages", "appium.webdriver", "5.2.0", "lib", "net6.0", "Appium.Net.dll"); - - if (File.Exists(nugetPath)) - { - return Assembly.LoadFrom(nugetPath); - } - - var localPath = Path.Combine(AppContext.BaseDirectory, "Appium.Net.dll"); - if (File.Exists(localPath)) - { - return Assembly.LoadFrom(localPath); - } - } - - return null; - } - - [SetUp] - public void SetUp() - { - App = CreateDriver(); - - // Handle permission dialogs on app startup - HandlePermissionDialogs(); - } - - private void HandlePermissionDialogs() - { - try - { - Thread.Sleep(2000); - var isIOS = App.GetType().Name.Contains("IOSDriver"); - - if (isIOS) - { - var iOSButtons = new[] { "Allow", "OK", "Allow While Using App", "Allow Once" }; - - for (int dialogRound = 1; dialogRound <= 2; dialogRound++) - { - foreach (var buttonText in iOSButtons) - { - try - { - var allowButton = App.FindElement(MobileBy.XPath($"//XCUIElementTypeButton[@name='{buttonText}']")); - if (allowButton.Displayed) - { - allowButton.Click(); - Thread.Sleep(3000); - break; - } - } - catch - { - } - } - Thread.Sleep(1000); - } - } - else - { - var androidButtons = new[] { "Allow", "ALLOW", "OK", "Accept" }; - - foreach (var buttonText in androidButtons) - { - try - { - var allowButton = App.FindElement(MobileBy.XPath($"//android.widget.Button[@text='{buttonText}']")); - if (allowButton.Displayed) - { - allowButton.Click(); - Thread.Sleep(1000); - } - } - catch - { - } - } - } - } - catch - { - } - } - - [TearDown] - public void TearDown() - { - App?.Quit(); - } -} \ No newline at end of file +using NUnit.Framework; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Android; +using OpenQA.Selenium.Appium.iOS; +using System.Reflection; + +namespace UITests.Shared; + +public abstract class BaseTest +{ + protected AppiumDriver App { get; set; } = null!; + private static bool _assemblyResolveHooked = false; + + protected abstract AppiumDriver CreateDriver(); + + [OneTimeSetUp] + public void OneTimeSetUp() + { + if (!_assemblyResolveHooked) + { + AppDomain.CurrentDomain.AssemblyResolve += ResolveAppiumAssembly; + _assemblyResolveHooked = true; + } + } + + private static Assembly? ResolveAppiumAssembly(object? sender, ResolveEventArgs args) + { + var assemblyName = new AssemblyName(args.Name); + + if (assemblyName.Name == "Appium.Net") + { + var nugetPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages", "appium.webdriver", "5.2.0", "lib", "net6.0", "Appium.Net.dll"); + + if (File.Exists(nugetPath)) + { + return Assembly.LoadFrom(nugetPath); + } + + var localPath = Path.Combine(AppContext.BaseDirectory, "Appium.Net.dll"); + if (File.Exists(localPath)) + { + return Assembly.LoadFrom(localPath); + } + } + + return null; + } + + [SetUp] + public void SetUp() + { + App = CreateDriver(); + + // Handle permission dialogs on app startup + HandlePermissionDialogs(); + } + + private void HandlePermissionDialogs() + { + try + { + Thread.Sleep(2000); + var isIOS = App.GetType().Name.Contains("IOSDriver"); + + if (isIOS) + { + var iOSButtons = new[] { "Allow", "OK", "Allow While Using App", "Allow Once" }; + + for (int dialogRound = 1; dialogRound <= 2; dialogRound++) + { + foreach (var buttonText in iOSButtons) + { + try + { + var allowButton = App.FindElement(MobileBy.XPath($"//XCUIElementTypeButton[@name='{buttonText}']")); + if (allowButton.Displayed) + { + allowButton.Click(); + Thread.Sleep(3000); + break; + } + } + catch + { + } + } + Thread.Sleep(1000); + } + } + else + { + var androidButtons = new[] { "Allow", "ALLOW", "OK", "Accept" }; + + foreach (var buttonText in androidButtons) + { + try + { + var allowButton = App.FindElement(MobileBy.XPath($"//android.widget.Button[@text='{buttonText}']")); + if (allowButton.Displayed) + { + allowButton.Click(); + Thread.Sleep(1000); + } + } + catch + { + } + } + } + } + catch + { + } + } + + [TearDown] + public void TearDown() + { + App?.Quit(); + } +} diff --git a/dotnet-maui/UITests.Shared/TaskSearchTests.cs b/dotnet-maui/UITests.Shared/TaskSearchTests.cs index fb53c12d1..ebf095104 100644 --- a/dotnet-maui/UITests.Shared/TaskSearchTests.cs +++ b/dotnet-maui/UITests.Shared/TaskSearchTests.cs @@ -1,87 +1,87 @@ -using NUnit.Framework; -using OpenQA.Selenium; -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Support.UI; - -namespace UITests.Shared; - -[TestFixture] -public abstract class TaskSearchTests : BaseTest -{ - [Test] - public void CanFindTaskByTitle() - { - var expectedTaskTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); - - if (string.IsNullOrEmpty(expectedTaskTitle)) - { - Assert.Fail("DITTO_CLOUD_TASK_TITLE environment variable is not set or empty"); - return; - } - - var isBrowserStack = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME")); - - try - { - // Wait for app to load and sync - longer for BrowserStack - var waitTime = isBrowserStack ? 15000 : 6000; - Thread.Sleep(waitTime); - - var taskFound = false; - - for (int attempt = 1; attempt <= 10; attempt++) - { - try - { - // Find all TaskTitleLabel elements using AutomationId (works on both iOS and Android) - var taskLabels = App.FindElements(MobileBy.Id("TaskTitleLabel")); - - foreach (var label in taskLabels) - { - try - { - if (label.Text.Contains(expectedTaskTitle) && label.Displayed) - { - taskFound = true; - break; - } - } - catch - { - // Label not accessible, continue - } - } - - if (taskFound) - break; - } - catch - { - Thread.Sleep(1000); - } - } - - if (!taskFound) - { - if (isBrowserStack) - { - App.ExecuteScript("browserstack_executor: {\"action\": \"setSessionStatus\", \"arguments\": {\"status\": \"failed\", \"reason\": \"Task not found\"}}"); - } - Assert.Fail($"Task '{expectedTaskTitle}' not found"); - } - - if (isBrowserStack) - { - App.ExecuteScript("browserstack_executor: {\"action\": \"setSessionStatus\", \"arguments\": {\"status\": \"passed\", \"reason\": \"Task found successfully\"}}"); - } - } - catch (Exception ex) - { - if (isBrowserStack) - { - App.ExecuteScript($"browserstack_executor: {{\"action\": \"setSessionStatus\", \"arguments\": {{\"status\": \"failed\", \"reason\": \"{ex.Message}\"}}}}"); - } - Assert.Fail($"Test failed: {ex.Message}"); - } - } -} \ No newline at end of file +using NUnit.Framework; +using OpenQA.Selenium; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Support.UI; + +namespace UITests.Shared; + +[TestFixture] +public abstract class TaskSearchTests : BaseTest +{ + [Test] + public void CanFindTaskByTitle() + { + var expectedTaskTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); + + if (string.IsNullOrEmpty(expectedTaskTitle)) + { + Assert.Fail("DITTO_CLOUD_TASK_TITLE environment variable is not set or empty"); + return; + } + + var isBrowserStack = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME")); + + try + { + // Wait for app to load and sync - longer for BrowserStack + var waitTime = isBrowserStack ? 15000 : 6000; + Thread.Sleep(waitTime); + + var taskFound = false; + + for (int attempt = 1; attempt <= 10; attempt++) + { + try + { + // Find all TaskTitleLabel elements using AutomationId (works on both iOS and Android) + var taskLabels = App.FindElements(MobileBy.Id("TaskTitleLabel")); + + foreach (var label in taskLabels) + { + try + { + if (label.Text.Contains(expectedTaskTitle) && label.Displayed) + { + taskFound = true; + break; + } + } + catch + { + // Label not accessible, continue + } + } + + if (taskFound) + break; + } + catch + { + Thread.Sleep(1000); + } + } + + if (!taskFound) + { + if (isBrowserStack) + { + App.ExecuteScript("browserstack_executor: {\"action\": \"setSessionStatus\", \"arguments\": {\"status\": \"failed\", \"reason\": \"Task not found\"}}"); + } + Assert.Fail($"Task '{expectedTaskTitle}' not found"); + } + + if (isBrowserStack) + { + App.ExecuteScript("browserstack_executor: {\"action\": \"setSessionStatus\", \"arguments\": {\"status\": \"passed\", \"reason\": \"Task found successfully\"}}"); + } + } + catch (Exception ex) + { + if (isBrowserStack) + { + App.ExecuteScript($"browserstack_executor: {{\"action\": \"setSessionStatus\", \"arguments\": {{\"status\": \"failed\", \"reason\": \"{ex.Message}\"}}}}"); + } + Assert.Fail($"Test failed: {ex.Message}"); + } + } +} diff --git a/dotnet-maui/UITests.iOS/Program.cs b/dotnet-maui/UITests.iOS/Program.cs index d6be342cd..9ada388c7 100644 --- a/dotnet-maui/UITests.iOS/Program.cs +++ b/dotnet-maui/UITests.iOS/Program.cs @@ -33,4 +33,4 @@ { // Ignore teardown errors } -} \ No newline at end of file +} diff --git a/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs b/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs index bc9a69e06..4a0d46ca5 100644 --- a/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs +++ b/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs @@ -1,127 +1,127 @@ -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Appium.iOS; -using UITests.Shared; -using System.Diagnostics; -using System.Text.RegularExpressions; - -namespace UITests.iOS; - -public class iOSTaskSearchTests : TaskSearchTests -{ - protected override AppiumDriver CreateDriver() - { - var options = new AppiumOptions(); - - // Check if running on BrowserStack - var browserstackUsername = Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME"); - var browserstackAccessKey = Environment.GetEnvironmentVariable("BROWSERSTACK_ACCESS_KEY"); - var browserstackApp = Environment.GetEnvironmentVariable("BROWSERSTACK_APP_ID"); - - if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) - { - // Load device config from environment (set by workflow from browserstack-devices.json) - var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); - if (string.IsNullOrEmpty(deviceString)) - { - throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); - } - var deviceParts = deviceString.Split('-'); - var deviceName = deviceParts[0]; - var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'iPhone 15-17.0')"); - - // BrowserStack capabilities for iOS - options.PlatformName = "iOS"; - options.AutomationName = "XCUITest"; - options.DeviceName = deviceName; - options.PlatformVersion = platformVersion; - options.App = browserstackApp ?? GetAppPath(); - options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); - options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); - options.AddAdditionalAppiumOption("name", "iOS Task Search Tests"); - options.AddAdditionalAppiumOption("autoGrantPermissions", true); - - var uri = new Uri($"https://{browserstackUsername}:{browserstackAccessKey}@hub-cloud.browserstack.com/wd/hub"); - return new IOSDriver(uri, options); - } - else - { - // Local testing capabilities - options.PlatformName = "iOS"; - options.AutomationName = "XCUITest"; - - // App capabilities - use the built .app - options.App = Path.Combine(GetAppPath(), "DittoMauiTasksApp.app"); - - // iOS Simulator capabilities - automatically detect booted simulator - var (deviceName, platformVersion) = GetFirstBootedSimulator(); - options.DeviceName = deviceName; - options.PlatformVersion = platformVersion; - options.AddAdditionalAppiumOption("newCommandTimeout", 300); - return new IOSDriver(new Uri("http://127.0.0.1:4723"), options); - } - } - - private string GetAppPath() - { - // Look for the .app in the MAUI project's build output - var projectRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); - var appPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-ios", "iossimulator-arm64"); - - if (!Directory.Exists(appPath)) - { - // Fallback to x64 simulator - appPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-ios", "iossimulator-x64"); - } - - return appPath; - } - - private static (string DeviceName, string PlatformVersion) GetFirstBootedSimulator() - { - var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = "xcrun", - Arguments = "simctl list devices", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - - process.Start(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - - // Parse output to find first booted device - var lines = output.Split('\n'); - string? currentRuntime = null; - - foreach (var line in lines) - { - // Check if line contains runtime version (e.g. "-- iOS 18.6 --") - var runtimeMatch = Regex.Match(line, @"-- iOS ([\d.]+) --"); - if (runtimeMatch.Success) - { - currentRuntime = runtimeMatch.Groups[1].Value; - continue; - } - - // Check if line contains a booted device - if (line.Contains("(Booted)") && currentRuntime != null) - { - // Extract device name (e.g. "iPhone 16 (8E5063C4-E64F-46BD-B445-1B22F85B3310) (Booted)") - var deviceMatch = Regex.Match(line, @"^\s+([^(]+)\s+\([^)]+\)\s+\(Booted\)"); - if (deviceMatch.Success) - { - var deviceName = deviceMatch.Groups[1].Value.Trim(); - return (deviceName, currentRuntime); - } - } - } - - // Fallback if no booted simulator found - throw new InvalidOperationException("No booted iOS simulators found. Please boot a simulator first."); - } -} \ No newline at end of file +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.iOS; +using UITests.Shared; +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace UITests.iOS; + +public class iOSTaskSearchTests : TaskSearchTests +{ + protected override AppiumDriver CreateDriver() + { + var options = new AppiumOptions(); + + // Check if running on BrowserStack + var browserstackUsername = Environment.GetEnvironmentVariable("BROWSERSTACK_USERNAME"); + var browserstackAccessKey = Environment.GetEnvironmentVariable("BROWSERSTACK_ACCESS_KEY"); + var browserstackApp = Environment.GetEnvironmentVariable("BROWSERSTACK_APP_ID"); + + if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) + { + // Load device config from environment (set by workflow from browserstack-devices.json) + var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); + if (string.IsNullOrEmpty(deviceString)) + { + throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); + } + var deviceParts = deviceString.Split('-'); + var deviceName = deviceParts[0]; + var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'iPhone 15-17.0')"); + + // BrowserStack capabilities for iOS + options.PlatformName = "iOS"; + options.AutomationName = "XCUITest"; + options.DeviceName = deviceName; + options.PlatformVersion = platformVersion; + options.App = browserstackApp ?? GetAppPath(); + options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); + options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); + options.AddAdditionalAppiumOption("name", "iOS Task Search Tests"); + options.AddAdditionalAppiumOption("autoGrantPermissions", true); + + var uri = new Uri($"https://{browserstackUsername}:{browserstackAccessKey}@hub-cloud.browserstack.com/wd/hub"); + return new IOSDriver(uri, options); + } + else + { + // Local testing capabilities + options.PlatformName = "iOS"; + options.AutomationName = "XCUITest"; + + // App capabilities - use the built .app + options.App = Path.Combine(GetAppPath(), "DittoMauiTasksApp.app"); + + // iOS Simulator capabilities - automatically detect booted simulator + var (deviceName, platformVersion) = GetFirstBootedSimulator(); + options.DeviceName = deviceName; + options.PlatformVersion = platformVersion; + options.AddAdditionalAppiumOption("newCommandTimeout", 300); + return new IOSDriver(new Uri("http://127.0.0.1:4723"), options); + } + } + + private string GetAppPath() + { + // Look for the .app in the MAUI project's build output + var projectRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var appPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-ios", "iossimulator-arm64"); + + if (!Directory.Exists(appPath)) + { + // Fallback to x64 simulator + appPath = Path.Combine(projectRoot, "DittoMauiTasksApp", "bin", "Debug", "net10.0-ios", "iossimulator-x64"); + } + + return appPath; + } + + private static (string DeviceName, string PlatformVersion) GetFirstBootedSimulator() + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "xcrun", + Arguments = "simctl list devices", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Parse output to find first booted device + var lines = output.Split('\n'); + string? currentRuntime = null; + + foreach (var line in lines) + { + // Check if line contains runtime version (e.g. "-- iOS 18.6 --") + var runtimeMatch = Regex.Match(line, @"-- iOS ([\d.]+) --"); + if (runtimeMatch.Success) + { + currentRuntime = runtimeMatch.Groups[1].Value; + continue; + } + + // Check if line contains a booted device + if (line.Contains("(Booted)") && currentRuntime != null) + { + // Extract device name (e.g. "iPhone 16 (8E5063C4-E64F-46BD-B445-1B22F85B3310) (Booted)") + var deviceMatch = Regex.Match(line, @"^\s+([^(]+)\s+\([^)]+\)\s+\(Booted\)"); + if (deviceMatch.Success) + { + var deviceName = deviceMatch.Groups[1].Value.Trim(); + return (deviceName, currentRuntime); + } + } + } + + // Fallback if no booted simulator found + throw new InvalidOperationException("No booted iOS simulators found. Please boot a simulator first."); + } +} diff --git a/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoDotNetTasksConsole.Tests.csproj b/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoDotNetTasksConsole.Tests.csproj index 9eb14b0ed..deac6e5c9 100644 --- a/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoDotNetTasksConsole.Tests.csproj +++ b/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoDotNetTasksConsole.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs b/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs index d71fc65a4..8d932203a 100644 --- a/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs +++ b/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs @@ -89,4 +89,4 @@ private static Dictionary LoadEnvVariables() return envVars; } -} \ No newline at end of file +} diff --git a/dotnet-tui/DittoDotNetTasksConsole/DittoDotNetTasksConsole.csproj b/dotnet-tui/DittoDotNetTasksConsole/DittoDotNetTasksConsole.csproj index 6cef927a6..f9307640c 100644 --- a/dotnet-tui/DittoDotNetTasksConsole/DittoDotNetTasksConsole.csproj +++ b/dotnet-tui/DittoDotNetTasksConsole/DittoDotNetTasksConsole.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs b/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs index 200fe4f4b..c71aac58d 100644 --- a/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs +++ b/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs @@ -281,4 +281,4 @@ public void StopSync() } _ditto.Sync.Stop(); } -} \ No newline at end of file +} diff --git a/dotnet-winforms-net48/.env.sample b/dotnet-winforms-net48/.env.sample index a05d0fcf6..180ee1e44 100644 --- a/dotnet-winforms-net48/.env.sample +++ b/dotnet-winforms-net48/.env.sample @@ -1,7 +1,7 @@ -#!/usr/bin/env bash - -# Copy this file from ".env.sample" to ".env", then fill in these values -# A Ditto AppID and Playground token can be obtained from https://portal.ditto.live -DITTO_APP_ID="" -DITTO_PLAYGROUND_TOKEN="" +#!/usr/bin/env bash + +# Copy this file from ".env.sample" to ".env", then fill in these values +# A Ditto AppID and Playground token can be obtained from https://portal.ditto.live +DITTO_APP_ID="" +DITTO_PLAYGROUND_TOKEN="" DITTO_AUTH_URL="" \ No newline at end of file diff --git a/dotnet-winforms-net48/App.config b/dotnet-winforms-net48/App.config index fcde7c8d9..533e4c0c2 100644 --- a/dotnet-winforms-net48/App.config +++ b/dotnet-winforms-net48/App.config @@ -1,18 +1,18 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-winforms-net48/MainForm.Designer.cs b/dotnet-winforms-net48/MainForm.Designer.cs index 241f35ba8..98f66fab3 100644 --- a/dotnet-winforms-net48/MainForm.Designer.cs +++ b/dotnet-winforms-net48/MainForm.Designer.cs @@ -1,164 +1,164 @@ -namespace Taskapp.WinForms.Net48 -{ - partial class MainForm - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - this.tasksListView = new System.Windows.Forms.ListView(); - this.doneColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); - this.titleColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); - this.idColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); - this.newTaskTextBox = new System.Windows.Forms.TextBox(); - this.addTaskButton = new System.Windows.Forms.Button(); - this.editTaskButton = new System.Windows.Forms.Button(); - this.deleteTaskButton = new System.Windows.Forms.Button(); - this.statusLabel = new System.Windows.Forms.Label(); - this.SuspendLayout(); - // - // tasksListView - // - this.tasksListView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.tasksListView.CheckBoxes = true; - this.tasksListView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { - this.doneColumn, - this.titleColumn, - this.idColumn}); - this.tasksListView.FullRowSelect = true; - this.tasksListView.GridLines = true; - this.tasksListView.HideSelection = false; - this.tasksListView.Location = new System.Drawing.Point(12, 12); - this.tasksListView.Name = "tasksListView"; - this.tasksListView.Size = new System.Drawing.Size(760, 350); - this.tasksListView.TabIndex = 0; - this.tasksListView.UseCompatibleStateImageBehavior = false; - this.tasksListView.View = System.Windows.Forms.View.Details; - this.tasksListView.ItemChecked += new System.Windows.Forms.ItemCheckedEventHandler(this.tasksListView_ItemChecked); - // - // doneColumn - // - this.doneColumn.Text = "Done"; - this.doneColumn.Width = 50; - // - // titleColumn - // - this.titleColumn.Text = "Title"; - this.titleColumn.Width = 500; - // - // idColumn - // - this.idColumn.Text = "ID"; - this.idColumn.Width = 0; - // - // newTaskTextBox - // - this.newTaskTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.newTaskTextBox.Location = new System.Drawing.Point(12, 378); - this.newTaskTextBox.Name = "newTaskTextBox"; - this.newTaskTextBox.Size = new System.Drawing.Size(540, 20); - this.newTaskTextBox.TabIndex = 1; - this.newTaskTextBox.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.newTaskTextBox_KeyPress); - // - // addTaskButton - // - this.addTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.addTaskButton.Location = new System.Drawing.Point(558, 376); - this.addTaskButton.Name = "addTaskButton"; - this.addTaskButton.Size = new System.Drawing.Size(100, 23); - this.addTaskButton.TabIndex = 2; - this.addTaskButton.Text = "Add Task"; - this.addTaskButton.UseVisualStyleBackColor = true; - this.addTaskButton.Click += new System.EventHandler(this.addTaskButton_Click); - // - // editTaskButton - // - this.editTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.editTaskButton.Location = new System.Drawing.Point(664, 376); - this.editTaskButton.Name = "editTaskButton"; - this.editTaskButton.Size = new System.Drawing.Size(100, 23); - this.editTaskButton.TabIndex = 3; - this.editTaskButton.Text = "Edit Task"; - this.editTaskButton.UseVisualStyleBackColor = true; - this.editTaskButton.Click += new System.EventHandler(this.editTaskButton_Click); - // - // deleteTaskButton - // - this.deleteTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.deleteTaskButton.Location = new System.Drawing.Point(664, 405); - this.deleteTaskButton.Name = "deleteTaskButton"; - this.deleteTaskButton.Size = new System.Drawing.Size(100, 23); - this.deleteTaskButton.TabIndex = 4; - this.deleteTaskButton.Text = "Delete Task"; - this.deleteTaskButton.UseVisualStyleBackColor = true; - this.deleteTaskButton.Click += new System.EventHandler(this.deleteTaskButton_Click); - // - // statusLabel - // - this.statusLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.statusLabel.AutoSize = true; - this.statusLabel.Location = new System.Drawing.Point(12, 410); - this.statusLabel.Name = "statusLabel"; - this.statusLabel.Size = new System.Drawing.Size(79, 13); - this.statusLabel.TabIndex = 5; - this.statusLabel.Text = "Status: Ready"; - // - // MainForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(784, 441); - this.Controls.Add(this.statusLabel); - this.Controls.Add(this.deleteTaskButton); - this.Controls.Add(this.editTaskButton); - this.Controls.Add(this.addTaskButton); - this.Controls.Add(this.newTaskTextBox); - this.Controls.Add(this.tasksListView); - this.Name = "MainForm"; - this.Text = "Tasks"; - this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing); - this.Load += new System.EventHandler(this.MainForm_Load); - this.ResumeLayout(false); - this.PerformLayout(); - - } - - #endregion - - private System.Windows.Forms.ListView tasksListView; - private System.Windows.Forms.ColumnHeader doneColumn; - private System.Windows.Forms.ColumnHeader titleColumn; - private System.Windows.Forms.ColumnHeader idColumn; - private System.Windows.Forms.TextBox newTaskTextBox; - private System.Windows.Forms.Button addTaskButton; - private System.Windows.Forms.Button editTaskButton; - private System.Windows.Forms.Button deleteTaskButton; - private System.Windows.Forms.Label statusLabel; - } -} - +namespace Taskapp.WinForms.Net48 +{ + partial class MainForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.tasksListView = new System.Windows.Forms.ListView(); + this.doneColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.titleColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.idColumn = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); + this.newTaskTextBox = new System.Windows.Forms.TextBox(); + this.addTaskButton = new System.Windows.Forms.Button(); + this.editTaskButton = new System.Windows.Forms.Button(); + this.deleteTaskButton = new System.Windows.Forms.Button(); + this.statusLabel = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // tasksListView + // + this.tasksListView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.tasksListView.CheckBoxes = true; + this.tasksListView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { + this.doneColumn, + this.titleColumn, + this.idColumn}); + this.tasksListView.FullRowSelect = true; + this.tasksListView.GridLines = true; + this.tasksListView.HideSelection = false; + this.tasksListView.Location = new System.Drawing.Point(12, 12); + this.tasksListView.Name = "tasksListView"; + this.tasksListView.Size = new System.Drawing.Size(760, 350); + this.tasksListView.TabIndex = 0; + this.tasksListView.UseCompatibleStateImageBehavior = false; + this.tasksListView.View = System.Windows.Forms.View.Details; + this.tasksListView.ItemChecked += new System.Windows.Forms.ItemCheckedEventHandler(this.tasksListView_ItemChecked); + // + // doneColumn + // + this.doneColumn.Text = "Done"; + this.doneColumn.Width = 50; + // + // titleColumn + // + this.titleColumn.Text = "Title"; + this.titleColumn.Width = 500; + // + // idColumn + // + this.idColumn.Text = "ID"; + this.idColumn.Width = 0; + // + // newTaskTextBox + // + this.newTaskTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.newTaskTextBox.Location = new System.Drawing.Point(12, 378); + this.newTaskTextBox.Name = "newTaskTextBox"; + this.newTaskTextBox.Size = new System.Drawing.Size(540, 20); + this.newTaskTextBox.TabIndex = 1; + this.newTaskTextBox.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.newTaskTextBox_KeyPress); + // + // addTaskButton + // + this.addTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.addTaskButton.Location = new System.Drawing.Point(558, 376); + this.addTaskButton.Name = "addTaskButton"; + this.addTaskButton.Size = new System.Drawing.Size(100, 23); + this.addTaskButton.TabIndex = 2; + this.addTaskButton.Text = "Add Task"; + this.addTaskButton.UseVisualStyleBackColor = true; + this.addTaskButton.Click += new System.EventHandler(this.addTaskButton_Click); + // + // editTaskButton + // + this.editTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.editTaskButton.Location = new System.Drawing.Point(664, 376); + this.editTaskButton.Name = "editTaskButton"; + this.editTaskButton.Size = new System.Drawing.Size(100, 23); + this.editTaskButton.TabIndex = 3; + this.editTaskButton.Text = "Edit Task"; + this.editTaskButton.UseVisualStyleBackColor = true; + this.editTaskButton.Click += new System.EventHandler(this.editTaskButton_Click); + // + // deleteTaskButton + // + this.deleteTaskButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.deleteTaskButton.Location = new System.Drawing.Point(664, 405); + this.deleteTaskButton.Name = "deleteTaskButton"; + this.deleteTaskButton.Size = new System.Drawing.Size(100, 23); + this.deleteTaskButton.TabIndex = 4; + this.deleteTaskButton.Text = "Delete Task"; + this.deleteTaskButton.UseVisualStyleBackColor = true; + this.deleteTaskButton.Click += new System.EventHandler(this.deleteTaskButton_Click); + // + // statusLabel + // + this.statusLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.statusLabel.AutoSize = true; + this.statusLabel.Location = new System.Drawing.Point(12, 410); + this.statusLabel.Name = "statusLabel"; + this.statusLabel.Size = new System.Drawing.Size(79, 13); + this.statusLabel.TabIndex = 5; + this.statusLabel.Text = "Status: Ready"; + // + // MainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(784, 441); + this.Controls.Add(this.statusLabel); + this.Controls.Add(this.deleteTaskButton); + this.Controls.Add(this.editTaskButton); + this.Controls.Add(this.addTaskButton); + this.Controls.Add(this.newTaskTextBox); + this.Controls.Add(this.tasksListView); + this.Name = "MainForm"; + this.Text = "Tasks"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.MainForm_FormClosing); + this.Load += new System.EventHandler(this.MainForm_Load); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.ListView tasksListView; + private System.Windows.Forms.ColumnHeader doneColumn; + private System.Windows.Forms.ColumnHeader titleColumn; + private System.Windows.Forms.ColumnHeader idColumn; + private System.Windows.Forms.TextBox newTaskTextBox; + private System.Windows.Forms.Button addTaskButton; + private System.Windows.Forms.Button editTaskButton; + private System.Windows.Forms.Button deleteTaskButton; + private System.Windows.Forms.Label statusLabel; + } +} + diff --git a/dotnet-winforms-net48/MainForm.cs b/dotnet-winforms-net48/MainForm.cs index a69a70339..493cd6bf7 100644 --- a/dotnet-winforms-net48/MainForm.cs +++ b/dotnet-winforms-net48/MainForm.cs @@ -1,253 +1,253 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Forms; - -namespace Taskapp.WinForms.Net48 -{ - public partial class MainForm : Form - { - private bool _isUpdatingListView = false; - - public MainForm() - { - InitializeComponent(); - } - - private void MainForm_Load(object sender, EventArgs e) - { - // Enable double buffering to reduce flicker - typeof(ListView).InvokeMember("DoubleBuffered", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.SetProperty, - null, tasksListView, new object[] { true }); - - // Register observer to watch for task changes - TasksPeerService.Instance.ObserveTasksCollection(OnTasksChanged); - - // Update status - UpdateSyncStatus(); - } - - private async Task OnTasksChanged(IList tasks) - { - // Invoke on UI thread - if (InvokeRequired) - { - Invoke(new Action(() => UpdateTasksList(tasks))); - } - else - { - UpdateTasksList(tasks); - } - - await Task.CompletedTask; - } - - private void UpdateTasksList(IList tasks) - { - _isUpdatingListView = true; - tasksListView.BeginUpdate(); - - try - { - // Filter out null tasks and tasks with null/empty Id - var validTasks = tasks - .Where(t => t != null && !string.IsNullOrEmpty(t.Id)) - .ToList(); - - // Create dictionary of current items by ID for quick lookup - var currentItems = new Dictionary(); - foreach (ListViewItem item in tasksListView.Items) - { - var id = item.SubItems[2].Text; // ID is in third column - currentItems[id] = item; - } - - // Create dictionary of incoming tasks by ID - var incomingTasks = validTasks.ToDictionary(t => t.Id); - - // Update existing items and add new ones - foreach (var task in validTasks) - { - if (currentItems.ContainsKey(task.Id)) - { - // Update existing item - var item = currentItems[task.Id]; - item.SubItems[1].Text = task.Title; - item.Checked = task.Done; - } - else - { - // Add new item - var item = new ListViewItem(); - item.Checked = task.Done; - item.SubItems.Add(task.Title); - item.SubItems.Add(task.Id); - tasksListView.Items.Add(item); - } - } - - // Remove items that are no longer in the incoming list - var itemsToRemove = new List(); - foreach (ListViewItem item in tasksListView.Items) - { - var id = item.SubItems[2].Text; - if (!incomingTasks.ContainsKey(id)) - { - itemsToRemove.Add(item); - } - } - - foreach (var item in itemsToRemove) - { - tasksListView.Items.Remove(item); - } - } - finally - { - tasksListView.EndUpdate(); - _isUpdatingListView = false; - } - } - - private async void addTaskButton_Click(object sender, EventArgs e) - { - var title = newTaskTextBox.Text.Trim(); - if (string.IsNullOrWhiteSpace(title)) - { - MessageBox.Show("Please enter a task title.", "Validation Error", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } - - try - { - await TasksPeerService.Instance.AddTaskAsync(title); - newTaskTextBox.Clear(); - UpdateStatus("Task added successfully"); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to add task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private async void editTaskButton_Click(object sender, EventArgs e) - { - if (tasksListView.SelectedItems.Count == 0) - { - MessageBox.Show("Please select a task to edit.", "No Selection", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - - var selectedItem = tasksListView.SelectedItems[0]; - var taskId = selectedItem.SubItems[2].Text; - var currentTitle = selectedItem.SubItems[1].Text; - - var newTitle = InputDialog.Show("Enter new title:", "Edit Task", currentTitle); - if (string.IsNullOrWhiteSpace(newTitle) || newTitle == currentTitle) - { - return; - } - - try - { - await TasksPeerService.Instance.UpdateTaskTitleAsync(taskId, newTitle); - UpdateStatus("Task updated successfully"); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to update task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - private async void deleteTaskButton_Click(object sender, EventArgs e) - { - if (tasksListView.SelectedItems.Count == 0) - { - MessageBox.Show("Please select a task to delete.", "No Selection", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - - var selectedItem = tasksListView.SelectedItems[0]; - var taskTitle = selectedItem.SubItems[1].Text; - var taskId = selectedItem.SubItems[2].Text; - - var result = MessageBox.Show( - $"Are you sure you want to delete the task '{taskTitle}'?", - "Confirm Delete", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question - ); - - if (result == DialogResult.Yes) - { - try - { - await TasksPeerService.Instance.DeleteTaskAsync(taskId); - UpdateStatus("Task deleted successfully"); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to delete task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - } - - private async void tasksListView_ItemChecked(object sender, ItemCheckedEventArgs e) - { - // Prevent recursive updates (including the revert flip below) - if (_isUpdatingListView) - return; - - var item = e.Item; - var taskId = item.SubItems[2].Text; - var newCheckedState = item.Checked; - - try - { - await TasksPeerService.Instance.UpdateTaskDoneAsync(taskId, newCheckedState); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to update task status: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - // Revert the check state; guard prevents this flip from re-entering - _isUpdatingListView = true; - item.Checked = !newCheckedState; - _isUpdatingListView = false; - } - } - - private void newTaskTextBox_KeyPress(object sender, KeyPressEventArgs e) - { - // Allow adding task with Enter key - if (e.KeyChar == (char)Keys.Enter) - { - e.Handled = true; - addTaskButton_Click(sender, EventArgs.Empty); - } - } - - private void UpdateStatus(string message) - { - statusLabel.Text = $"Status: {message}"; - } - - private void UpdateSyncStatus() - { - if (TasksPeerService.Instance.IsSyncActive) - { - statusLabel.Text = "Status: Syncing"; - } - else - { - statusLabel.Text = "Status: Ready"; - } - } - - private void MainForm_FormClosing(object sender, FormClosingEventArgs e) - { - // Cleanup handled in Program.cs - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace Taskapp.WinForms.Net48 +{ + public partial class MainForm : Form + { + private bool _isUpdatingListView = false; + + public MainForm() + { + InitializeComponent(); + } + + private void MainForm_Load(object sender, EventArgs e) + { + // Enable double buffering to reduce flicker + typeof(ListView).InvokeMember("DoubleBuffered", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.SetProperty, + null, tasksListView, new object[] { true }); + + // Register observer to watch for task changes + TasksPeerService.Instance.ObserveTasksCollection(OnTasksChanged); + + // Update status + UpdateSyncStatus(); + } + + private async Task OnTasksChanged(IList tasks) + { + // Invoke on UI thread + if (InvokeRequired) + { + Invoke(new Action(() => UpdateTasksList(tasks))); + } + else + { + UpdateTasksList(tasks); + } + + await Task.CompletedTask; + } + + private void UpdateTasksList(IList tasks) + { + _isUpdatingListView = true; + tasksListView.BeginUpdate(); + + try + { + // Filter out null tasks and tasks with null/empty Id + var validTasks = tasks + .Where(t => t != null && !string.IsNullOrEmpty(t.Id)) + .ToList(); + + // Create dictionary of current items by ID for quick lookup + var currentItems = new Dictionary(); + foreach (ListViewItem item in tasksListView.Items) + { + var id = item.SubItems[2].Text; // ID is in third column + currentItems[id] = item; + } + + // Create dictionary of incoming tasks by ID + var incomingTasks = validTasks.ToDictionary(t => t.Id); + + // Update existing items and add new ones + foreach (var task in validTasks) + { + if (currentItems.ContainsKey(task.Id)) + { + // Update existing item + var item = currentItems[task.Id]; + item.SubItems[1].Text = task.Title; + item.Checked = task.Done; + } + else + { + // Add new item + var item = new ListViewItem(); + item.Checked = task.Done; + item.SubItems.Add(task.Title); + item.SubItems.Add(task.Id); + tasksListView.Items.Add(item); + } + } + + // Remove items that are no longer in the incoming list + var itemsToRemove = new List(); + foreach (ListViewItem item in tasksListView.Items) + { + var id = item.SubItems[2].Text; + if (!incomingTasks.ContainsKey(id)) + { + itemsToRemove.Add(item); + } + } + + foreach (var item in itemsToRemove) + { + tasksListView.Items.Remove(item); + } + } + finally + { + tasksListView.EndUpdate(); + _isUpdatingListView = false; + } + } + + private async void addTaskButton_Click(object sender, EventArgs e) + { + var title = newTaskTextBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + MessageBox.Show("Please enter a task title.", "Validation Error", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + try + { + await TasksPeerService.Instance.AddTaskAsync(title); + newTaskTextBox.Clear(); + UpdateStatus("Task added successfully"); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to add task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async void editTaskButton_Click(object sender, EventArgs e) + { + if (tasksListView.SelectedItems.Count == 0) + { + MessageBox.Show("Please select a task to edit.", "No Selection", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var selectedItem = tasksListView.SelectedItems[0]; + var taskId = selectedItem.SubItems[2].Text; + var currentTitle = selectedItem.SubItems[1].Text; + + var newTitle = InputDialog.Show("Enter new title:", "Edit Task", currentTitle); + if (string.IsNullOrWhiteSpace(newTitle) || newTitle == currentTitle) + { + return; + } + + try + { + await TasksPeerService.Instance.UpdateTaskTitleAsync(taskId, newTitle); + UpdateStatus("Task updated successfully"); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to update task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async void deleteTaskButton_Click(object sender, EventArgs e) + { + if (tasksListView.SelectedItems.Count == 0) + { + MessageBox.Show("Please select a task to delete.", "No Selection", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var selectedItem = tasksListView.SelectedItems[0]; + var taskTitle = selectedItem.SubItems[1].Text; + var taskId = selectedItem.SubItems[2].Text; + + var result = MessageBox.Show( + $"Are you sure you want to delete the task '{taskTitle}'?", + "Confirm Delete", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question + ); + + if (result == DialogResult.Yes) + { + try + { + await TasksPeerService.Instance.DeleteTaskAsync(taskId); + UpdateStatus("Task deleted successfully"); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to delete task: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private async void tasksListView_ItemChecked(object sender, ItemCheckedEventArgs e) + { + // Prevent recursive updates (including the revert flip below) + if (_isUpdatingListView) + return; + + var item = e.Item; + var taskId = item.SubItems[2].Text; + var newCheckedState = item.Checked; + + try + { + await TasksPeerService.Instance.UpdateTaskDoneAsync(taskId, newCheckedState); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to update task status: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + // Revert the check state; guard prevents this flip from re-entering + _isUpdatingListView = true; + item.Checked = !newCheckedState; + _isUpdatingListView = false; + } + } + + private void newTaskTextBox_KeyPress(object sender, KeyPressEventArgs e) + { + // Allow adding task with Enter key + if (e.KeyChar == (char)Keys.Enter) + { + e.Handled = true; + addTaskButton_Click(sender, EventArgs.Empty); + } + } + + private void UpdateStatus(string message) + { + statusLabel.Text = $"Status: {message}"; + } + + private void UpdateSyncStatus() + { + if (TasksPeerService.Instance.IsSyncActive) + { + statusLabel.Text = "Status: Syncing"; + } + else + { + statusLabel.Text = "Status: Ready"; + } + } + + private void MainForm_FormClosing(object sender, FormClosingEventArgs e) + { + // Cleanup handled in Program.cs + } + } +} diff --git a/dotnet-winforms-net48/MainForm.resx b/dotnet-winforms-net48/MainForm.resx index 29dcb1b3a..1af7de150 100644 --- a/dotnet-winforms-net48/MainForm.resx +++ b/dotnet-winforms-net48/MainForm.resx @@ -1,120 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + \ No newline at end of file diff --git a/dotnet-winforms-net48/Program.cs b/dotnet-winforms-net48/Program.cs index e5b69f174..106d6792d 100644 --- a/dotnet-winforms-net48/Program.cs +++ b/dotnet-winforms-net48/Program.cs @@ -1,68 +1,68 @@ -using System; -using System.Windows.Forms; - -namespace Taskapp.WinForms.Net48 -{ - internal static class Program - { - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - - try - { - // Load configuration from .env file - AppConfiguration.Load(); - - // Initialize TasksPeerService asynchronously - var initTask = TasksPeerService.Instance.InitializeAsync( - AppConfiguration.AppId, - AppConfiguration.PlaygroundToken, - AppConfiguration.AuthUrl - ); - - // Show loading form while initializing - using (var loadingForm = new LoadingForm(initTask)) - { - if (loadingForm.ShowDialog() == DialogResult.OK) - { - // Initialization successful, show main form - Application.Run(new MainForm()); - } - else - { - // Initialization failed or was cancelled - MessageBox.Show( - "Failed to initialize Ditto. Please check your configuration and try again.", - "Initialization Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); - } - } - } - catch (Exception ex) - { - MessageBox.Show( - $"Application startup failed: {ex.Message}", - "Startup Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); - } - finally - { - // Cleanup on exit - if (TasksPeerService.Instance.IsInitialized) - { - TasksPeerService.Instance.Dispose(); - } - } - } - } -} +using System; +using System.Windows.Forms; + +namespace Taskapp.WinForms.Net48 +{ + internal static class Program + { + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + try + { + // Load configuration from .env file + AppConfiguration.Load(); + + // Initialize TasksPeerService asynchronously + var initTask = TasksPeerService.Instance.InitializeAsync( + AppConfiguration.AppId, + AppConfiguration.PlaygroundToken, + AppConfiguration.AuthUrl + ); + + // Show loading form while initializing + using (var loadingForm = new LoadingForm(initTask)) + { + if (loadingForm.ShowDialog() == DialogResult.OK) + { + // Initialization successful, show main form + Application.Run(new MainForm()); + } + else + { + // Initialization failed or was cancelled + MessageBox.Show( + "Failed to initialize Ditto. Please check your configuration and try again.", + "Initialization Error", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + } + } + } + catch (Exception ex) + { + MessageBox.Show( + $"Application startup failed: {ex.Message}", + "Startup Error", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + } + finally + { + // Cleanup on exit + if (TasksPeerService.Instance.IsInitialized) + { + TasksPeerService.Instance.Dispose(); + } + } + } + } +} diff --git a/dotnet-winforms-net48/Properties/AssemblyInfo.cs b/dotnet-winforms-net48/Properties/AssemblyInfo.cs index 94f3c000b..e4fb49ca4 100644 --- a/dotnet-winforms-net48/Properties/AssemblyInfo.cs +++ b/dotnet-winforms-net48/Properties/AssemblyInfo.cs @@ -1,33 +1,33 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Taskapp.WinForms.Net48")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Ditto")] -[assembly: AssemblyProduct("Taskapp.WinForms.Net48")] -[assembly: AssemblyCopyright("Copyright © Ditto Live 2026")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("9f61044a-836b-42c3-974e-1cca09ee86a2")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Taskapp.WinForms.Net48")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Ditto")] +[assembly: AssemblyProduct("Taskapp.WinForms.Net48")] +[assembly: AssemblyCopyright("Copyright © Ditto Live 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("9f61044a-836b-42c3-974e-1cca09ee86a2")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/dotnet-winforms-net48/Properties/Resources.Designer.cs b/dotnet-winforms-net48/Properties/Resources.Designer.cs index c6bdade56..dc069845d 100644 --- a/dotnet-winforms-net48/Properties/Resources.Designer.cs +++ b/dotnet-winforms-net48/Properties/Resources.Designer.cs @@ -1,71 +1,71 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Taskapp.WinForms.Net48.Properties -{ - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources - { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() - { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager - { - get - { - if ((resourceMan == null)) - { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Taskapp.WinForms.Net48.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture - { - get - { - return resourceCulture; - } - set - { - resourceCulture = value; - } - } - } -} +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Taskapp.WinForms.Net48.Properties +{ + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources + { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() + { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if ((resourceMan == null)) + { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Taskapp.WinForms.Net48.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get + { + return resourceCulture; + } + set + { + resourceCulture = value; + } + } + } +} diff --git a/dotnet-winforms-net48/Properties/Resources.resx b/dotnet-winforms-net48/Properties/Resources.resx index ffecec851..af7dbebba 100644 --- a/dotnet-winforms-net48/Properties/Resources.resx +++ b/dotnet-winforms-net48/Properties/Resources.resx @@ -1,117 +1,117 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + \ No newline at end of file diff --git a/dotnet-winforms-net48/Properties/Settings.Designer.cs b/dotnet-winforms-net48/Properties/Settings.Designer.cs index 28a61c00a..761f2f69a 100644 --- a/dotnet-winforms-net48/Properties/Settings.Designer.cs +++ b/dotnet-winforms-net48/Properties/Settings.Designer.cs @@ -1,30 +1,30 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Taskapp.WinForms.Net48.Properties -{ - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase - { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default - { - get - { - return defaultInstance; - } - } - } -} +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Taskapp.WinForms.Net48.Properties +{ + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase + { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default + { + get + { + return defaultInstance; + } + } + } +} diff --git a/dotnet-winforms-net48/Properties/Settings.settings b/dotnet-winforms-net48/Properties/Settings.settings index abf36c5d3..39645652a 100644 --- a/dotnet-winforms-net48/Properties/Settings.settings +++ b/dotnet-winforms-net48/Properties/Settings.settings @@ -1,7 +1,7 @@ - - - - - - - + + + + + + + diff --git a/dotnet-winforms-net48/Taskapp.WinForms.Net48.csproj b/dotnet-winforms-net48/Taskapp.WinForms.Net48.csproj index 7f60ecbc5..0561fc473 100644 --- a/dotnet-winforms-net48/Taskapp.WinForms.Net48.csproj +++ b/dotnet-winforms-net48/Taskapp.WinForms.Net48.csproj @@ -1,161 +1,166 @@ - - - - - Debug - AnyCPU - {9F61044A-836B-42C3-974E-1CCA09EE86A2} - WinExe - Taskapp.WinForms.Net48 - Taskapp.WinForms.Net48 - v4.8 - 512 - true - true - - - x64 - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - x64 - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false - - - - packages\PeterO.Cbor.4.5.2\lib\net40\CBOR.dll - - - packages\Ditto.5.0.1\lib\netstandard2.0\Ditto.dll - - - packages\Ditto.Native.Linux.5.0.1\lib\netstandard2.0\Ditto.Native.Linux.dll - - - packages\Ditto.Native.MacOS.5.0.1\lib\netstandard2.0\Ditto.Native.MacOS.dll - - - packages\Ditto.Native.Win.5.0.1\lib\netstandard2.0\Ditto.Native.Win.dll - - - packages\Microsoft.Bcl.AsyncInterfaces.8.0.0\lib\net462\Microsoft.Bcl.AsyncInterfaces.dll - - - packages\Microsoft.Bcl.HashCode.1.1.1\lib\net461\Microsoft.Bcl.HashCode.dll - - - packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll - - - packages\PeterO.Numbers.1.8.2\lib\net40\Numbers.dll - - - - packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll - - - - packages\System.Memory.4.5.5\lib\net461\System.Memory.dll - - - - packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll - - - packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll - - - packages\System.Text.Encodings.Web.8.0.0\lib\net462\System.Text.Encodings.Web.dll - - - packages\System.Text.Json.8.0.5\lib\net462\System.Text.Json.dll - - - packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll - - - packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll - - - - - - - - - - - - packages\PeterO.URIUtility.1.0.0\lib\net40\URIUtility.dll - - - - - - - Form - - - LoadingForm.cs - - - Form - - - MainForm.cs - - - - - - - - MainForm.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - PreserveNewest - - - - - dittoffi.dll - PreserveNewest - - - + + + + + Debug + AnyCPU + {9F61044A-836B-42C3-974E-1CCA09EE86A2} + WinExe + Taskapp.WinForms.Net48 + Taskapp.WinForms.Net48 + v4.8 + 512 + true + true + + + x64 + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + x64 + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + + packages\PeterO.Cbor.4.5.2\lib\net40\CBOR.dll + + + + packages\Ditto.5.0.2\lib\netstandard2.0\Ditto.dll + False + + + packages\Ditto.Native.Linux.5.0.2\lib\netstandard2.0\Ditto.Native.Linux.dll + + + packages\Ditto.Native.MacOS.5.0.2\lib\netstandard2.0\Ditto.Native.MacOS.dll + + + packages\Ditto.Native.Win.5.0.2\lib\netstandard2.0\Ditto.Native.Win.dll + + + packages\Microsoft.Bcl.AsyncInterfaces.8.0.0\lib\net462\Microsoft.Bcl.AsyncInterfaces.dll + + + packages\Microsoft.Bcl.HashCode.1.1.1\lib\net461\Microsoft.Bcl.HashCode.dll + + + packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll + + + packages\PeterO.Numbers.1.8.2\lib\net40\Numbers.dll + + + + packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + + packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + + + + packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + + packages\System.Text.Encodings.Web.8.0.0\lib\net462\System.Text.Encodings.Web.dll + + + packages\System.Text.Json.8.0.5\lib\net462\System.Text.Json.dll + + + packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + + + packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + + + + + + + + + + + + packages\PeterO.URIUtility.1.0.0\lib\net40\URIUtility.dll + + + + + + + Form + + + LoadingForm.cs + + + Form + + + MainForm.cs + + + + + + + + MainForm.cs + + + ResXFileCodeGenerator + Resources.Designer.cs + Designer + + + True + Resources.resx + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + Settings.settings + True + + + + + + PreserveNewest + + + + + dittoffi.dll + PreserveNewest + + + \ No newline at end of file diff --git a/dotnet-winforms-net48/Taskapp.WinForms.Net48.sln b/dotnet-winforms-net48/Taskapp.WinForms.Net48.sln index d9d5c4ad9..782f61171 100644 --- a/dotnet-winforms-net48/Taskapp.WinForms.Net48.sln +++ b/dotnet-winforms-net48/Taskapp.WinForms.Net48.sln @@ -1,25 +1,25 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28729.10 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Taskapp.WinForms.Net48", "Taskapp.WinForms.Net48.csproj", "{9F61044A-836B-42C3-974E-1CCA09EE86A2}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {FEC06644-F3C3-4034-843B-D871F0B794C8} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28729.10 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Taskapp.WinForms.Net48", "Taskapp.WinForms.Net48.csproj", "{9F61044A-836B-42C3-974E-1CCA09EE86A2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F61044A-836B-42C3-974E-1CCA09EE86A2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FEC06644-F3C3-4034-843B-D871F0B794C8} + EndGlobalSection +EndGlobal diff --git a/dotnet-winforms-net48/TasksPeer.cs b/dotnet-winforms-net48/TasksPeer.cs index 4d57b06f9..b5a0bf553 100644 --- a/dotnet-winforms-net48/TasksPeer.cs +++ b/dotnet-winforms-net48/TasksPeer.cs @@ -1,290 +1,290 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -using DittoSDK; -using DittoSDK.Auth; -using DittoSDK.Store; -using Newtonsoft.Json; - -/// -/// Encapsulates use of the Ditto SDK and the 'tasks' collection. -/// - -namespace Taskapp.WinForms.Net48 -{ - public class TasksPeer : IDisposable - { - private const string Query = "SELECT * FROM tasks WHERE NOT deleted"; - - public string AppId { get; private set; } - public string PlaygroundToken { get; private set; } - public string AuthUrl { get; private set; } - - public bool IsSyncActive => _ditto.Sync.IsActive; - - private readonly Ditto _ditto; - - /// - /// Creates a new synchronizing TasksPeer instance. - /// - public static async Task Create( - string appId, - string playgroundToken, - string authUrl) - { - var peer = new TasksPeer(appId, playgroundToken, authUrl); - peer.Authenticate(); - peer.RegisterSubscription(); - await peer.InsertInitialTasks(); - peer.StartSync(); - - return peer; - } - - private void Authenticate() - { - _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => - { - // Authenticate when token is expiring - try - { - await ditto.Auth.LoginAsync( - // Your development token, replace with your actual token - PlaygroundToken, - // Use DittoAuthenticationProvider.Development for playground, or your actual provider - DittoAuthenticationProvider.Development - ); - Console.WriteLine("Authentication successful"); - } - catch (Exception error) - { - Console.WriteLine($"Authentication failed: {error}"); - } - }; - } - - /// - /// Registers a subscription for the tasks collection to enable data synchronization - /// with other peers and the Ditto cloud. This subscription determines what data - /// will be synced to this peer during the synchronization process. - /// - /// - /// The subscription is created using the same query that filters out deleted tasks - /// (SELECT * FROM tasks WHERE NOT deleted), ensuring that only active, - /// non-deleted tasks are synchronized across the network. - /// - /// This method should be called during peer initialization to establish the - /// subscription before starting the sync process. The subscription remains active - /// until explicitly cancelled or when the peer is disposed. - /// - /// - private void RegisterSubscription() - { - // Register a subscription, which determines what data syncs to this peer - // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions - _ditto.Sync.RegisterSubscription(Query); - } - - /// - /// Constructor - /// - /// Ditto application ID - /// Ditto online playground token - /// Ditto Auth URL - private TasksPeer(string appId, string playgroundToken, string authUrl) - { - AppId = appId; - PlaygroundToken = playgroundToken; - AuthUrl = authUrl; - - var config = new DittoConfig( - AppId, - new DittoConfigConnect.Server(new Uri(authUrl)) - ); - - _ditto = Ditto.Open(config); - } - - public void Dispose() - { - _ditto.Dispose(); - GC.SuppressFinalize(this); - } - - /// - /// Inserts the initial tasks into the 'tasks' collection. - /// - private async Task InsertInitialTasks() - { - var initialTasks = new List> - { - new Dictionary - { - {"_id", "50191411-4C46-4940-8B72-5F8017A04FA7"}, - {"title", "Buy groceries"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "6DA283DA-8CFE-4526-A6FA-D385089364E5"}, - {"title", "Clean the kitchen"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "5303DDF8-0E72-4FEB-9E82-4B007E5797F0"}, - {"title", "Schedule dentist appointment"}, - {"done", false}, - {"deleted", false} - }, - new Dictionary - { - {"_id", "38411F1B-6B49-4346-90C3-0B16CE97E174"}, - {"title", "Pay bills"}, - {"done", false}, - {"deleted", false} - } - }; - - const string insertCommand = "INSERT INTO tasks INITIAL DOCUMENTS (:task)"; - foreach (var task in initialTasks) - { - await _ditto.Store.ExecuteAsync(insertCommand, new Dictionary() - { - { "task", task } - }); - } - } - - /// - /// Adds a new task to the 'tasks' collection. - /// - public async Task AddTask(string title) - { - if (string.IsNullOrWhiteSpace(title)) - { - throw new ArgumentException("title cannot be empty"); - } - - var doc = new Dictionary - { - {"title", title}, - {"done", false}, - {"deleted", false } - }; - const string insertCommand = "INSERT INTO tasks DOCUMENTS (:doc)"; - await _ditto.Store.ExecuteAsync(insertCommand, new Dictionary() - { - { "doc", doc } - }); - } - - /// - /// Update the title of an existing task. - /// - public async Task UpdateTaskTitle(string taskId, string newTitle) - { - if (string.IsNullOrWhiteSpace(taskId)) - { - throw new ArgumentException("taskId cannot be empty"); - } - - if (string.IsNullOrWhiteSpace(newTitle)) - { - throw new ArgumentException("title cannot be empty"); - } - - const string updateQuery = "UPDATE tasks SET title = :title WHERE _id = :id"; - await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary() - { - {"title", newTitle}, - {"id", taskId} - }); - } - - /// - /// Mark a task as deleted. - /// - public async Task DeleteTask(string taskId) - { - if (string.IsNullOrWhiteSpace(taskId)) - { - throw new ArgumentException("taskId cannot be empty"); - } - - const string updateQuery = "UPDATE tasks SET deleted = true WHERE _id = :id"; - await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary() - { - { "id", taskId } - }); - } - - /// - /// Mark a task as complete or not complete. - /// - public async Task UpdateTaskDone(string taskId, bool newDoneState) - { - const string updateQuery = "UPDATE tasks SET done = :newDoneState WHERE _id = :id AND done != :newDoneState"; - await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary - { - { "newDoneState", newDoneState }, - { "id", taskId } - }); - } - - /// - /// Specify a handler to be called asynchronously when the task collection changes. - /// - public DittoStoreObserver ObserveTasksCollection(Func, Task> handler) - { - // Register an observer, which runs against the local database on this peer - // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers - return _ditto.Store.RegisterObserver(Query, async (queryResult) => - { - try - { - // Deserialize the JSON documents into ToDoTask objects - var tasks = queryResult.Items.Select(d => - { - using (var reader = new StringReader(d.JsonString())) - using (var jsonReader = new JsonTextReader(reader)) - { - return JsonSerializer.Create().Deserialize(jsonReader); - } - }).OrderBy(t => t?.Id).ToList(); - - await handler(tasks); - } - catch (Exception e) - { - Console.Error.WriteLine($"ERROR tasks observation handler failed: {e.Message}"); - } - }); - } - - /// - /// Start synchronizing the 'tasks' collection. - /// - public void StartSync() - { - _ditto.Sync.Start(); - } - - /// - /// Stop synchronizing the 'tasks' collection. - /// - public void StopSync() - { - foreach (var subscription in _ditto.Sync.Subscriptions) - { - subscription.Cancel(); - } - _ditto.Sync.Stop(); - } - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using DittoSDK; +using DittoSDK.Auth; +using DittoSDK.Store; +using Newtonsoft.Json; + +/// +/// Encapsulates use of the Ditto SDK and the 'tasks' collection. +/// + +namespace Taskapp.WinForms.Net48 +{ + public class TasksPeer : IDisposable + { + private const string Query = "SELECT * FROM tasks WHERE NOT deleted"; + + public string AppId { get; private set; } + public string PlaygroundToken { get; private set; } + public string AuthUrl { get; private set; } + + public bool IsSyncActive => _ditto.Sync.IsActive; + + private readonly Ditto _ditto; + + /// + /// Creates a new synchronizing TasksPeer instance. + /// + public static async Task Create( + string appId, + string playgroundToken, + string authUrl) + { + var peer = new TasksPeer(appId, playgroundToken, authUrl); + peer.Authenticate(); + peer.RegisterSubscription(); + await peer.InsertInitialTasks(); + peer.StartSync(); + + return peer; + } + + private void Authenticate() + { + _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => + { + // Authenticate when token is expiring + try + { + await ditto.Auth.LoginAsync( + // Your development token, replace with your actual token + PlaygroundToken, + // Use DittoAuthenticationProvider.Development for playground, or your actual provider + DittoAuthenticationProvider.Development + ); + Console.WriteLine("Authentication successful"); + } + catch (Exception error) + { + Console.WriteLine($"Authentication failed: {error}"); + } + }; + } + + /// + /// Registers a subscription for the tasks collection to enable data synchronization + /// with other peers and the Ditto cloud. This subscription determines what data + /// will be synced to this peer during the synchronization process. + /// + /// + /// The subscription is created using the same query that filters out deleted tasks + /// (SELECT * FROM tasks WHERE NOT deleted), ensuring that only active, + /// non-deleted tasks are synchronized across the network. + /// + /// This method should be called during peer initialization to establish the + /// subscription before starting the sync process. The subscription remains active + /// until explicitly cancelled or when the peer is disposed. + /// + /// + private void RegisterSubscription() + { + // Register a subscription, which determines what data syncs to this peer + // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions + _ditto.Sync.RegisterSubscription(Query); + } + + /// + /// Constructor + /// + /// Ditto application ID + /// Ditto online playground token + /// Ditto Auth URL + private TasksPeer(string appId, string playgroundToken, string authUrl) + { + AppId = appId; + PlaygroundToken = playgroundToken; + AuthUrl = authUrl; + + var config = new DittoConfig( + AppId, + new DittoConfigConnect.Server(new Uri(authUrl)) + ); + + _ditto = Ditto.Open(config); + } + + public void Dispose() + { + _ditto.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Inserts the initial tasks into the 'tasks' collection. + /// + private async Task InsertInitialTasks() + { + var initialTasks = new List> + { + new Dictionary + { + {"_id", "50191411-4C46-4940-8B72-5F8017A04FA7"}, + {"title", "Buy groceries"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "6DA283DA-8CFE-4526-A6FA-D385089364E5"}, + {"title", "Clean the kitchen"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "5303DDF8-0E72-4FEB-9E82-4B007E5797F0"}, + {"title", "Schedule dentist appointment"}, + {"done", false}, + {"deleted", false} + }, + new Dictionary + { + {"_id", "38411F1B-6B49-4346-90C3-0B16CE97E174"}, + {"title", "Pay bills"}, + {"done", false}, + {"deleted", false} + } + }; + + const string insertCommand = "INSERT INTO tasks INITIAL DOCUMENTS (:task)"; + foreach (var task in initialTasks) + { + await _ditto.Store.ExecuteAsync(insertCommand, new Dictionary() + { + { "task", task } + }); + } + } + + /// + /// Adds a new task to the 'tasks' collection. + /// + public async Task AddTask(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("title cannot be empty"); + } + + var doc = new Dictionary + { + {"title", title}, + {"done", false}, + {"deleted", false } + }; + const string insertCommand = "INSERT INTO tasks DOCUMENTS (:doc)"; + await _ditto.Store.ExecuteAsync(insertCommand, new Dictionary() + { + { "doc", doc } + }); + } + + /// + /// Update the title of an existing task. + /// + public async Task UpdateTaskTitle(string taskId, string newTitle) + { + if (string.IsNullOrWhiteSpace(taskId)) + { + throw new ArgumentException("taskId cannot be empty"); + } + + if (string.IsNullOrWhiteSpace(newTitle)) + { + throw new ArgumentException("title cannot be empty"); + } + + const string updateQuery = "UPDATE tasks SET title = :title WHERE _id = :id"; + await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary() + { + {"title", newTitle}, + {"id", taskId} + }); + } + + /// + /// Mark a task as deleted. + /// + public async Task DeleteTask(string taskId) + { + if (string.IsNullOrWhiteSpace(taskId)) + { + throw new ArgumentException("taskId cannot be empty"); + } + + const string updateQuery = "UPDATE tasks SET deleted = true WHERE _id = :id"; + await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary() + { + { "id", taskId } + }); + } + + /// + /// Mark a task as complete or not complete. + /// + public async Task UpdateTaskDone(string taskId, bool newDoneState) + { + const string updateQuery = "UPDATE tasks SET done = :newDoneState WHERE _id = :id AND done != :newDoneState"; + await _ditto.Store.ExecuteAsync(updateQuery, new Dictionary + { + { "newDoneState", newDoneState }, + { "id", taskId } + }); + } + + /// + /// Specify a handler to be called asynchronously when the task collection changes. + /// + public DittoStoreObserver ObserveTasksCollection(Func, Task> handler) + { + // Register an observer, which runs against the local database on this peer + // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers + return _ditto.Store.RegisterObserver(Query, async (queryResult) => + { + try + { + // Deserialize the JSON documents into ToDoTask objects + var tasks = queryResult.Items.Select(d => + { + using (var reader = new StringReader(d.JsonString())) + using (var jsonReader = new JsonTextReader(reader)) + { + return JsonSerializer.Create().Deserialize(jsonReader); + } + }).OrderBy(t => t?.Id).ToList(); + + await handler(tasks); + } + catch (Exception e) + { + Console.Error.WriteLine($"ERROR tasks observation handler failed: {e.Message}"); + } + }); + } + + /// + /// Start synchronizing the 'tasks' collection. + /// + public void StartSync() + { + _ditto.Sync.Start(); + } + + /// + /// Stop synchronizing the 'tasks' collection. + /// + public void StopSync() + { + foreach (var subscription in _ditto.Sync.Subscriptions) + { + subscription.Cancel(); + } + _ditto.Sync.Stop(); + } + } +} diff --git a/dotnet-winforms-net48/ToDoTask.cs b/dotnet-winforms-net48/ToDoTask.cs index 109c553a4..b532aa63c 100644 --- a/dotnet-winforms-net48/ToDoTask.cs +++ b/dotnet-winforms-net48/ToDoTask.cs @@ -1,29 +1,29 @@ -using Newtonsoft.Json; -using System; - -namespace Taskapp.WinForms.Net48 -{ - /// - /// Representation of a document in the Ditto 'tasks' collection. - /// - /// - /// This class is named ToDoTask rather than Task to avoid - /// conflicts with the System.Threading.Tasks.Task class. - /// - public class ToDoTask - { - [JsonProperty("_id")] - public string Id { get; set; } - - [JsonProperty("title")] - public string Title { get; set; } - - [JsonProperty("done")] - public bool Done { get; set; } - - [JsonProperty("deleted")] - public bool Deleted { get; set; } - - override public string ToString() => Title; - } -} +using Newtonsoft.Json; +using System; + +namespace Taskapp.WinForms.Net48 +{ + /// + /// Representation of a document in the Ditto 'tasks' collection. + /// + /// + /// This class is named ToDoTask rather than Task to avoid + /// conflicts with the System.Threading.Tasks.Task class. + /// + public class ToDoTask + { + [JsonProperty("_id")] + public string Id { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("done")] + public bool Done { get; set; } + + [JsonProperty("deleted")] + public bool Deleted { get; set; } + + override public string ToString() => Title; + } +} diff --git a/dotnet-winforms-net48/packages.config b/dotnet-winforms-net48/packages.config index df16735fe..1baeb46d1 100644 --- a/dotnet-winforms-net48/packages.config +++ b/dotnet-winforms-net48/packages.config @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-winforms/IntegrationTest/Program.cs b/dotnet-winforms/IntegrationTest/Program.cs index 5d7bfded4..528dbd8f3 100644 --- a/dotnet-winforms/IntegrationTest/Program.cs +++ b/dotnet-winforms/IntegrationTest/Program.cs @@ -147,4 +147,4 @@ static async Task Main(string[] args) return 1; } } -} \ No newline at end of file +} diff --git a/dotnet-winforms/TasksApp/DittoTasksApp.csproj b/dotnet-winforms/TasksApp/DittoTasksApp.csproj index cc3a97312..4c0a20e6c 100644 --- a/dotnet-winforms/TasksApp/DittoTasksApp.csproj +++ b/dotnet-winforms/TasksApp/DittoTasksApp.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet-winforms/TasksApp/Program.cs b/dotnet-winforms/TasksApp/Program.cs index 6394c2e2e..1dd51d52a 100644 --- a/dotnet-winforms/TasksApp/Program.cs +++ b/dotnet-winforms/TasksApp/Program.cs @@ -81,4 +81,4 @@ private static Dictionary LoadEnvVariables() return envVars; } } -} \ No newline at end of file +} diff --git a/electron/package.json b/electron/package.json index b7bdea6ea..497f569de 100644 --- a/electron/package.json +++ b/electron/package.json @@ -20,7 +20,7 @@ "format": "prettier --write ." }, "dependencies": { - "@dittolive/ditto": "5.0.0", + "@dittolive/ditto": "5.0.2", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/electron/tailwind.config.js b/electron/tailwind.config.js index 6e88978ca..f708040d3 100644 --- a/electron/tailwind.config.js +++ b/electron/tailwind.config.js @@ -1,9 +1,6 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - './src/renderer/index.html', - './src/renderer/**/*.{js,ts,jsx,tsx}', - ], + content: ['./src/renderer/index.html', './src/renderer/**/*.{js,ts,jsx,tsx}'], theme: { extend: {}, }, diff --git a/electron/tsconfig.web.json b/electron/tsconfig.web.json index c9a88efd8..be1796858 100644 --- a/electron/tsconfig.web.json +++ b/electron/tsconfig.web.json @@ -20,9 +20,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": [ - "src/renderer/**/*", - "src/preload/api.d.ts", - "src/types.ts" - ] + "include": ["src/renderer/**/*", "src/preload/api.d.ts", "src/types.ts"] } diff --git a/flutter_app/android/build.gradle b/flutter_app/android/build.gradle index 0c47cbc0a..08282f487 100644 --- a/flutter_app/android/build.gradle +++ b/flutter_app/android/build.gradle @@ -20,6 +20,12 @@ subprojects { // breaks the manifest merger. Bump every Android subproject (notably // :ditto_live) to match. subprojects { + // :app sets its own minSdkVersion and is already evaluated (forced by + // evaluationDependsOn above); registering afterEvaluate on an already- + // evaluated project throws in Gradle 8.10. Skip it here — only plugin + // library subprojects (e.g. :ditto_live) need the bump. + if (project.name == 'app') return + afterEvaluate { project -> if (project.plugins.hasPlugin('com.android.application') || project.plugins.hasPlugin('com.android.library')) { diff --git a/flutter_app/android/gradlew.bat b/flutter_app/android/gradlew.bat index 9b42019c7..9d21a2183 100755 --- a/flutter_app/android/gradlew.bat +++ b/flutter_app/android/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/flutter_app/integration_test/app_test.dart b/flutter_app/integration_test/app_test.dart index 4adacedf6..df30a14ee 100644 --- a/flutter_app/integration_test/app_test.dart +++ b/flutter_app/integration_test/app_test.dart @@ -13,8 +13,9 @@ void main() { await dotenv.load(fileName: ".env"); }); - testWidgets('App loads and syncs with Ditto Cloud', - (WidgetTester tester) async { + testWidgets('App loads and syncs with Ditto Cloud', ( + WidgetTester tester, + ) async { // Initialize app await app.main(); // Allow up to 10 seconds for Ditto to initialise and the first sync @@ -45,8 +46,10 @@ void main() { const testTitle = String.fromEnvironment('TASK_TO_FIND'); if (testTitle.isEmpty) { - throw Exception('TASK_TO_FIND environment variable must be set. ' - 'Build with: --dart-define=TASK_TO_FIND='); + throw Exception( + 'TASK_TO_FIND environment variable must be set. ' + 'Build with: --dart-define=TASK_TO_FIND=', + ); } // Poll every 500 ms for up to 45 seconds to give the cloud sync @@ -63,9 +66,12 @@ void main() { } } - expect(taskFound, isTrue, - reason: - 'Should find test document with title: $testTitle synced from Ditto cloud within ${syncTimeout.inSeconds}s'); + expect( + taskFound, + isTrue, + reason: + 'Should find test document with title: $testTitle synced from Ditto cloud within ${syncTimeout.inSeconds}s', + ); }); }); } diff --git a/flutter_app/lib/dialog.dart b/flutter_app/lib/dialog.dart index 09ba560ae..b32da35ac 100644 --- a/flutter_app/lib/dialog.dart +++ b/flutter_app/lib/dialog.dart @@ -3,10 +3,7 @@ import 'package:flutter/material.dart'; import 'task.dart'; Future showAddTaskDialog(BuildContext context, [Task? task]) => - showDialog( - context: context, - builder: (context) => _Dialog(task), - ); + showDialog(context: context, builder: (context) => _Dialog(task)); class _Dialog extends StatefulWidget { final Task? taskToEdit; @@ -34,10 +31,7 @@ class _DialogState extends State<_Dialog> { content: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, - children: [ - _textInput(_name, "Name"), - _doneSwitch, - ], + children: [_textInput(_name, "Name"), _doneSwitch], ), actions: [ TextButton( @@ -47,11 +41,7 @@ class _DialogState extends State<_Dialog> { ElevatedButton( child: Text(widget.taskToEdit == null ? "Add Task" : "Edit Task"), onPressed: () { - final task = Task( - title: _name.text, - done: _done, - deleted: false, - ); + final task = Task(title: _name.text, done: _done, deleted: false); Navigator.of(context).pop(task); }, ), @@ -61,9 +51,7 @@ class _DialogState extends State<_Dialog> { Widget _textInput(TextEditingController controller, String label) => ListTile( title: TextField( controller: controller, - decoration: InputDecoration( - labelText: label, - ), + decoration: InputDecoration(labelText: label), ), ); diff --git a/flutter_app/lib/dql_builder.dart b/flutter_app/lib/dql_builder.dart index 1240dc1f2..ae4dbae14 100644 --- a/flutter_app/lib/dql_builder.dart +++ b/flutter_app/lib/dql_builder.dart @@ -98,12 +98,13 @@ class _DqlBuilderState extends State { if (stream == null) return placeholder; return StreamBuilder( - stream: stream, - builder: (context, snapshot) { - final response = snapshot.data; - if (response == null) return widget.loading ?? _defaultLoading; - return widget.builder(context, response); - }); + stream: stream, + builder: (context, snapshot) { + final response = snapshot.data; + if (response == null) return widget.loading ?? _defaultLoading; + return widget.builder(context, response); + }, + ); } } diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index e1ec06f22..719f36f59 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -52,8 +52,10 @@ class _DittoExampleState extends State { /// 7. Starts sync and updates the app state with the configured Ditto instance Future _initDitto() async { // Skip permissions in test mode - they block integration tests - const isTestMode = - bool.fromEnvironment('INTEGRATION_TEST_MODE', defaultValue: false); + const isTestMode = bool.fromEnvironment( + 'INTEGRATION_TEST_MODE', + defaultValue: false, + ); // Only request permissions on mobile platforms (Android/iOS) // Desktop platforms (macOS, Windows, Linux) don't require these permissions @@ -63,7 +65,7 @@ class _DittoExampleState extends State { Permission.bluetoothConnect, Permission.bluetoothAdvertise, Permission.nearbyWifiDevices, - Permission.bluetoothScan + Permission.bluetoothScan, ].request(); } @@ -74,11 +76,15 @@ class _DittoExampleState extends State { //new configuration - https://docs.ditto.live/sdk/latest/ditto-config final config = DittoConfig( - databaseID: appID, connect: DittoConfigConnectServer(url: authUrl)); + databaseID: appID, + connect: DittoConfigConnectServer(url: authUrl), + ); final ditto = await Ditto.open(config); await ditto.auth.setExpirationHandler((ditto, timeUntilExpiration) async { - final authResult = await ditto.auth - .login(token: token, provider: Authenticator.developmentProvider); + final authResult = await ditto.auth.login( + token: token, + provider: Authenticator.developmentProvider, + ); if (authResult.exception != null) { throw authResult.exception!; } @@ -153,7 +159,7 @@ class _DittoExampleState extends State { children: [ const CircularProgressIndicator(), const Text("Ensure your AppID and Token are correct"), - _portalInfo + _portalInfo, ], ), ), @@ -164,10 +170,8 @@ class _DittoExampleState extends State { child: const Icon(Icons.add_task), ); - Widget get _portalInfo => Column(children: [ - Text("AppID: $appID"), - Text("Token: $token"), - ]); + Widget get _portalInfo => + Column(children: [Text("AppID: $appID"), Text("Token: $token")]); Widget get _syncTile => SwitchListTile( title: const Text("Sync Active"), @@ -189,9 +193,7 @@ class _DittoExampleState extends State { query: "SELECT * FROM tasks WHERE deleted = false", builder: (context, result) { final tasks = result.items.map((r) => r.value).map(Task.fromJson); - return ListView( - children: tasks.map(_singleTask).toList(), - ); + return ListView(children: tasks.map(_singleTask).toList()); }, ); @@ -206,9 +208,10 @@ class _DittoExampleState extends State { ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Deleted Task ${task.title}")), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar(content: Text("Deleted Task ${task.title}"))); } }, background: _dismissibleBackground(true), diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 7244f6995..cee16fd04 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -1,8 +1,8 @@ name: flutter_quickstart -description: "Demonstrates how to use the ditto_live plugin." +description: 'Demonstrates how to use the ditto_live plugin.' # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +publish_to: 'none' # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -19,8 +19,8 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=3.3.0 <4.0.0" - flutter: ">=3.24.0" + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.24.0' # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -32,7 +32,7 @@ dependencies: flutter: sdk: flutter - ditto_live: 5.0.0 + ditto_live: 5.0.2 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -67,8 +67,8 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - # environment file for Ditto Config values - - .env + # environment file for Ditto Config values + - .env # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/flutter_app/test/dql_safety_test.dart b/flutter_app/test/dql_safety_test.dart new file mode 100644 index 000000000..3be9fc9f6 --- /dev/null +++ b/flutter_app/test/dql_safety_test.dart @@ -0,0 +1,71 @@ +// Regression test for the parameterized-DQL fix. +// +// Earlier versions of this file built UPDATE statements by interpolating +// user-typed task titles into DQL strings, e.g.: +// +// "UPDATE tasks SET title = '${newTask.title}' where _id = '${task.id}'" +// +// A task titled `'); EVICT FROM tasks WHERE true; --` could break out of +// the quoted value and run arbitrary DQL. The fix switched to named +// parameter binding (`:title`, `:done`, `:id`) with values passed via the +// `arguments:` map. +// +// This test pins the fix as a static-source check: any future change that +// reintroduces Dart string interpolation into the DQL strings in +// `lib/main.dart` fails here loudly. It does not exercise the DQL engine — +// a real round-trip test would need a live `Ditto` instance, which lives in +// `integration_test/`. The check is intentionally coarse and operates on +// the file as text. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('main.dart DQL safety', () { + late String source; + + setUpAll(() { + source = File('lib/main.dart').readAsStringSync(); + }); + + test('uses named parameter binding for every mutable field', () { + expect(source, contains(':task'), reason: 'INSERT binding missing'); + expect(source, contains(':title'), reason: 'updateTitle binding missing'); + expect(source, contains(':done'), reason: 'setDone binding missing'); + expect(source, contains(':id'), + reason: 'WHERE _id = :id binding missing'); + }); + + test('no Dart string interpolation inside DQL execute() calls', () { + // Find every `_ditto!.store.execute(` (or `.execute(`) call site and + // inspect the string literal that follows up to the next `,` or `)`. + // Forbid `${` and `$identifier` inside those literals. + final calls = RegExp(r'\.execute\(\s*"([^"]*)"').allMatches(source); + expect( + calls.isNotEmpty, + isTrue, + reason: 'No execute() calls found — has main.dart moved?', + ); + + final shortInterp = RegExp(r'\$[a-zA-Z_{][a-zA-Z0-9_]*'); + for (final m in calls) { + final query = m.group(1)!; + expect( + query, + isNot(contains(r'${')), + reason: 'Found `\${` inside DQL: $query — use named binding.', + ); + final hit = shortInterp.firstMatch(query); + expect( + hit, + isNull, + reason: hit == null + ? '' + : 'Found `${hit.group(0)}` (Dart interpolation) inside DQL: ' + '$query — replace with `:name` and pass via `arguments:`.', + ); + } + }); + }); +} diff --git a/flutter_app/test/widget_test.dart b/flutter_app/test/widget_test.dart index 1b4d96200..9639126ce 100644 --- a/flutter_app/test/widget_test.dart +++ b/flutter_app/test/widget_test.dart @@ -39,11 +39,7 @@ void main() { }); test('toJson omits null id', () { - const task = Task( - title: 'New task', - done: false, - deleted: false, - ); + const task = Task(title: 'New task', done: false, deleted: false); final json = task.toJson(); @@ -69,8 +65,9 @@ void main() { }); group('Add task dialog', () { - testWidgets('shows Add Task title for new task', - (WidgetTester tester) async { + testWidgets('shows Add Task title for new task', ( + WidgetTester tester, + ) async { await tester.pumpWidget( MaterialApp( home: Builder( @@ -91,8 +88,9 @@ void main() { expect(find.text('Cancel'), findsOneWidget); }); - testWidgets('shows Edit Task title when editing existing task', - (WidgetTester tester) async { + testWidgets('shows Edit Task title when editing existing task', ( + WidgetTester tester, + ) async { const existing = Task( id: '1', title: 'Existing task', diff --git a/global.json b/global.json new file mode 100644 index 000000000..5721f12f1 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "//": "The quickstart .NET apps target net10.0; this shadows the monorepo root global.json (which pins the 8.x SDK the core repo builds with) so `dotnet` resolves a .NET 10 SDK for everything under quickstart/.", + "sdk": { + "version": "10.0.100", + "rollForward": "latestPatch" + } +} diff --git a/java-server/build.gradle.kts b/java-server/build.gradle.kts index d3099051c..74313fc53 100644 --- a/java-server/build.gradle.kts +++ b/java-server/build.gradle.kts @@ -31,29 +31,29 @@ spotbugs { dependencies { // ditto-java artifact includes the Java API for Ditto - implementation("com.ditto:ditto-java:5.0.0") + implementation("com.ditto:ditto-java:5.0.2") // This will include binaries for all the supported platforms and architectures - implementation("com.ditto:ditto-binaries:5.0.0") + implementation("com.ditto:ditto-binaries:5.0.2") // To reduce your module artifact's size, consider including just the necessary platforms and architectures /* // macOS Apple Silicon - implementation("com.ditto:ditto-binaries:5.0.0") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-macos-arm64") } } // Windows x86_64 - implementation("com.ditto:ditto-binaries:5.0.0") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-windows-x64") } } // Linux x86_64 - implementation("com.ditto:ditto-binaries:5.0.0") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-linux-x64") } diff --git a/java-server/java-spring-maven/pom.xml b/java-server/java-spring-maven/pom.xml index a8ed7ec43..e4f23deaa 100644 --- a/java-server/java-spring-maven/pom.xml +++ b/java-server/java-spring-maven/pom.xml @@ -34,14 +34,14 @@ com.ditto ditto-java - 5.0.0-preview.3 + 5.0.2 com.ditto ditto-binaries - 5.0.0-preview.3 + 5.0.2 diff --git a/java-server/settings.gradle.kts b/java-server/settings.gradle.kts index 46a5b6491..2dd67738e 100644 --- a/java-server/settings.gradle.kts +++ b/java-server/settings.gradle.kts @@ -14,7 +14,7 @@ pluginManagement { } plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } rootProject.name = "quickstart-java" diff --git a/javascript-tui/.prettierignore b/javascript-tui/.prettierignore index 1521c8b76..67af58370 100644 --- a/javascript-tui/.prettierignore +++ b/javascript-tui/.prettierignore @@ -1 +1,2 @@ dist +package-lock.json diff --git a/javascript-tui/README.md b/javascript-tui/README.md index 41cc1c0b5..6951d79a7 100644 --- a/javascript-tui/README.md +++ b/javascript-tui/README.md @@ -73,10 +73,10 @@ Each task document has the following structure: ```json { - "_id": "unique-id", - "title": "Task description", - "done": false, - "deleted": false + "_id": "unique-id", + "title": "Task description", + "done": false, + "deleted": false } ``` diff --git a/javascript-tui/package.json b/javascript-tui/package.json index ec3fbb81e..65aabae1b 100644 --- a/javascript-tui/package.json +++ b/javascript-tui/package.json @@ -1,68 +1,66 @@ { - "name": "javascript", - "version": "0.0.0", - "license": "MIT", - "bin": "dist/cli.js", - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "start": "npm run build && node dist/cli.js", - "build": "babel --out-dir=dist source", - "dev": "babel --out-dir=dist --watch source", - "test": "npm run test:format && npm run test:integration", - "test:format": "prettier --check .", - "test:integration": "npm run build && DITTO_CLOUD_TASK_TITLE=\"\" node tests/integration_test.js", - "format": "prettier --write ." - }, - "files": [ - "dist" - ], - "dependencies": { - "@dittolive/ditto": "5.0.0", - "dotenv": "^16.4.5", - "ink": "^4.1.0", - "meow": "^11.0.0", - "react": "^18.2.0", - "tempy": "^3.1.0" - }, - "devDependencies": { - "@babel/cli": "^7.21.0", - "@babel/preset-env": "^7.28.3", - "@babel/preset-react": "^7.18.6", - "@vdemedes/prettier-config": "^2.0.1", - "eslint": "^9.18.0", - "eslint-config-xo-react": "^0.30.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.1.1", - "ink-testing-library": "^4.0.0", - "prettier": "^2.8.7" - }, - "overrides": { - "tar": "7.5.11", - "undici": "6.24.1", - "flatted": "3.4.2", - "lodash": "4.18.1", - "glob": "^13.0.6" - }, - "xo": { - "extends": "xo-react", - "prettier": true, - "rules": { - "react/prop-types": "off" - } - }, - "prettier": "@vdemedes/prettier-config", - "babel": { - "presets": [ - [ - "@babel/preset-env", - { - "modules": false - } - ], - "@babel/preset-react" - ] - } + "name": "javascript", + "version": "0.0.0", + "license": "MIT", + "bin": "dist/cli.js", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "npm run build && node dist/cli.js", + "build": "babel --out-dir=dist source", + "dev": "babel --out-dir=dist --watch source", + "test": "npm run test:format && npm run test:integration", + "test:format": "prettier --check .", + "test:integration": "npm run build && DITTO_CLOUD_TASK_TITLE=\"\" node tests/integration_test.js", + "format": "prettier --write ." + }, + "files": [ + "dist" + ], + "dependencies": { + "@dittolive/ditto": "5.0.2", + "dotenv": "^16.4.5", + "ink": "^4.1.0", + "meow": "^11.0.0", + "react": "^18.2.0", + "tempy": "^3.1.0" + }, + "devDependencies": { + "@babel/cli": "^7.21.0", + "@babel/preset-env": "^7.28.3", + "@babel/preset-react": "^7.18.6", + "eslint": "^9.18.0", + "eslint-config-xo-react": "^0.30.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "ink-testing-library": "^4.0.0", + "prettier": "^2.8.7" + }, + "overrides": { + "tar": "7.5.11", + "undici": "6.24.1", + "flatted": "3.4.2", + "lodash": "4.18.1", + "glob": "^13.0.6" + }, + "xo": { + "extends": "xo-react", + "prettier": true, + "rules": { + "react/prop-types": "off" + } + }, + "babel": { + "presets": [ + [ + "@babel/preset-env", + { + "modules": false + } + ], + "@babel/preset-react" + ] + } } diff --git a/javascript-tui/prettier.config.cjs b/javascript-tui/prettier.config.cjs new file mode 100644 index 000000000..c3ba00a99 --- /dev/null +++ b/javascript-tui/prettier.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + singleQuote: true, + trailingComma: 'all', + tabWidth: 2, + semi: true, + printWidth: 80, +}; diff --git a/javascript-tui/prettier.config.js b/javascript-tui/prettier.config.js deleted file mode 100644 index d39a4a367..000000000 --- a/javascript-tui/prettier.config.js +++ /dev/null @@ -1,9 +0,0 @@ -const config = { - singleQuote: true, - trailingComma: 'all', - tabWidth: 2, - semi: true, - printWidth: 80, -}; - -export default config; diff --git a/javascript-tui/source/app.js b/javascript-tui/source/app.js index 14c6e9844..6c24a1efc 100644 --- a/javascript-tui/source/app.js +++ b/javascript-tui/source/app.js @@ -1,331 +1,331 @@ -import React, {useEffect, useRef, useState} from 'react'; -import {Text, Spacer, Box, useInput} from 'ink'; +import React, { useEffect, useRef, useState } from 'react'; +import { Text, Spacer, Box, useInput } from 'ink'; const enterAltScreenCommand = '\x1b[?1049h'; const leaveAltScreenCommand = '\x1b[?1049l'; -export default function App({ditto}) { - return ( - - - - - - ); +export default function App({ ditto }) { + return ( + + + + + + ); } // Helper component to make TUI fullscreen and wrap with a border -const FullScreen = props => { - const [size, setSize] = useState({ - columns: process.stdout.columns, - rows: process.stdout.rows, - }); - - useEffect(() => { - function onResize() { - setSize({ - columns: process.stdout.columns, - rows: process.stdout.rows - 1, - }); - } - - process.stdout.on('resize', onResize); - process.stdout.write(enterAltScreenCommand); - return () => { - process.stdout.off('resize', onResize); - process.stdout.write(leaveAltScreenCommand); - }; - }, []); - - return ( - - {props.children} - - ); +const FullScreen = (props) => { + const [size, setSize] = useState({ + columns: process.stdout.columns, + rows: process.stdout.rows, + }); + + useEffect(() => { + function onResize() { + setSize({ + columns: process.stdout.columns, + rows: process.stdout.rows - 1, + }); + } + + process.stdout.on('resize', onResize); + process.stdout.write(enterAltScreenCommand); + return () => { + process.stdout.off('resize', onResize); + process.stdout.write(leaveAltScreenCommand); + }; + }, []); + + return ( + + {props.children} + + ); }; -const HelpPanel = props => { - const [showHelp, setShowHelp] = useState(true); - - useInput((input, _key) => { - if (input === '?') { - setShowHelp(!showHelp); - } - }); - - if (showHelp) { - return ( - <> - - {props.children} - - - ? - toggle help - ↑/k - scroll up - ↓/j - scroll down - c - create task - d - delete task - e - edit task - s - toggle sync - q - quit - Enter - toggle done - - - - ); - } - - return <>{props.children}; +const HelpPanel = (props) => { + const [showHelp, setShowHelp] = useState(true); + + useInput((input, _key) => { + if (input === '?') { + setShowHelp(!showHelp); + } + }); + + if (showHelp) { + return ( + <> + + {props.children} + + + ? - toggle help + ↑/k - scroll up + ↓/j - scroll down + c - create task + d - delete task + e - edit task + s - toggle sync + q - quit + Enter - toggle done + + + + ); + } + + return <>{props.children}; }; const LIST_MODE = 'list'; const CREATE_MODE = 'create'; const EDIT_MODE = 'edit'; -const TodoApp = ({ditto}) => { - const [tasks, setTasks] = useState([]); - const [mode, setMode] = useState(LIST_MODE); - const [selected, setSelected] = useState(0); - const [syncEnabled, setSyncEnabled] = useState(true); - - // Hold onto the subscription and observer refs for cleanup - const subscriptionRef = useRef(null); - const observerRef = useRef(null); - - useEffect(() => { - return () => { - const subscription = subscriptionRef.current; - const observer = observerRef.current; - - void subscription; - void observer; - }; - }, []); - - useInput((input, key) => { - if (mode === LIST_MODE) { - if (input === 'c') { - setMode(CREATE_MODE); - return; - } - if (input === 'e') { - setMode(EDIT_MODE); - return; - } - if (input === 's') { - if (syncEnabled) { - ditto.sync.stop(); - setSyncEnabled(false); - } else { - ditto.sync.start(); - setSyncEnabled(true); - } - } - } - - if (key.escape) { - setMode(LIST_MODE); - return; - } - }); - - useEffect(() => { - // Register a subscription, which determines what data syncs to this peer - // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions - const subscription = ditto.sync.registerSubscription('SELECT * FROM tasks'); - subscriptionRef.current = subscription; - - // Register observer, which runs against the local database on this peer - // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers - const observer = ditto.store.registerObserver( - 'SELECT * FROM tasks WHERE NOT deleted ORDER BY title ASC', - result => { - const tasks = result.items.map(item => item.value); - setTasks(tasks); - }, - ); - observerRef.current = observer; - - // Cleanup on unmount: cancel subscription and observer - return () => { - subscription.cancel(); - observer.cancel(); - }; - }, [ditto]); - - const Prompt = React.memo(({edit}) => { - const newTask = !edit; - const initialText = newTask ? '' : edit.title; - const [text, setText] = useState(initialText); - - useInput((input, key) => { - if (key.backspace || key.delete) { - // Chop off last char and set - setText(text.slice(0, -1)); - return; - } - - if (key.return) { - if (newTask) { - (async () => { - await createTask(ditto, text); - })(); - } else { - (async () => { - await updateTask(ditto, edit._id, text); - })(); - } - - // On submission: - setText(''); // Reset input field - setMode(LIST_MODE); // Set app back to "list" mode - return; - } - - const newContent = text + input; - setText(newContent); - }); - - return Title: {text}; - }); - - const List = React.memo(({tasks}) => { - useInput((input, key) => { - // Scroll up - if (input === 'k' || key.upArrow) { - if (selected > 0) { - setSelected(selected - 1); - } - return; - } - - // Scroll down - if (input === 'j' || key.downArrow) { - if (selected < tasks.length - 1) { - setSelected(selected + 1); - } - return; - } - - // Delete - if (input === 'd') { - if (tasks.length > 0) { - (async () => { - await deleteTask(ditto, tasks[selected]); - })(); - } - } - - // Quit - if (input === 'q') { - process.stdout.write('\x1B[?25h'); // Make cursor visible - process.exit(0); - } - - if (key.return) { - (async () => { - await toggleDone(ditto, tasks[selected]); - })(); - } - }); - - return ( - - {tasks.map((task, i) => { - const done = task.done ? ' 🟢 ' : ' ⚪️ '; - const highlight = selected === i ? 'blue' : ''; - const cursor = selected === i ? '❯ ' : ' '; - return ( - - - {done} - - {cursor} - {task.title} - - - - ); - })} - - ); - }); - - const syncStatus = syncEnabled ? '🟢 Sync Active' : '🔴 Sync Inactive'; - const syncText = {syncStatus}; - - if (mode === LIST_MODE) { - return ( - - {syncText} - Done Title - - - ); - } - - if (mode === CREATE_MODE) { - return ( - - {syncText} - Create new Task - - - ); - } - - if (mode === EDIT_MODE) { - const selectedTask = tasks[selected]; - return ( - - {syncText} - Edit Task - - - ); - } +const TodoApp = ({ ditto }) => { + const [tasks, setTasks] = useState([]); + const [mode, setMode] = useState(LIST_MODE); + const [selected, setSelected] = useState(0); + const [syncEnabled, setSyncEnabled] = useState(true); + + // Hold onto the subscription and observer refs for cleanup + const subscriptionRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + return () => { + const subscription = subscriptionRef.current; + const observer = observerRef.current; + + void subscription; + void observer; + }; + }, []); + + useInput((input, key) => { + if (mode === LIST_MODE) { + if (input === 'c') { + setMode(CREATE_MODE); + return; + } + if (input === 'e') { + setMode(EDIT_MODE); + return; + } + if (input === 's') { + if (syncEnabled) { + ditto.sync.stop(); + setSyncEnabled(false); + } else { + ditto.sync.start(); + setSyncEnabled(true); + } + } + } + + if (key.escape) { + setMode(LIST_MODE); + return; + } + }); + + useEffect(() => { + // Register a subscription, which determines what data syncs to this peer + // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions + const subscription = ditto.sync.registerSubscription('SELECT * FROM tasks'); + subscriptionRef.current = subscription; + + // Register observer, which runs against the local database on this peer + // https://docs.ditto.live/sdk/latest/crud/observing-data-changes#setting-up-store-observers + const observer = ditto.store.registerObserver( + 'SELECT * FROM tasks WHERE NOT deleted ORDER BY title ASC', + (result) => { + const tasks = result.items.map((item) => item.value); + setTasks(tasks); + }, + ); + observerRef.current = observer; + + // Cleanup on unmount: cancel subscription and observer + return () => { + subscription.cancel(); + observer.cancel(); + }; + }, [ditto]); + + const Prompt = React.memo(({ edit }) => { + const newTask = !edit; + const initialText = newTask ? '' : edit.title; + const [text, setText] = useState(initialText); + + useInput((input, key) => { + if (key.backspace || key.delete) { + // Chop off last char and set + setText(text.slice(0, -1)); + return; + } + + if (key.return) { + if (newTask) { + (async () => { + await createTask(ditto, text); + })(); + } else { + (async () => { + await updateTask(ditto, edit._id, text); + })(); + } + + // On submission: + setText(''); // Reset input field + setMode(LIST_MODE); // Set app back to "list" mode + return; + } + + const newContent = text + input; + setText(newContent); + }); + + return Title: {text}; + }); + + const List = React.memo(({ tasks }) => { + useInput((input, key) => { + // Scroll up + if (input === 'k' || key.upArrow) { + if (selected > 0) { + setSelected(selected - 1); + } + return; + } + + // Scroll down + if (input === 'j' || key.downArrow) { + if (selected < tasks.length - 1) { + setSelected(selected + 1); + } + return; + } + + // Delete + if (input === 'd') { + if (tasks.length > 0) { + (async () => { + await deleteTask(ditto, tasks[selected]); + })(); + } + } + + // Quit + if (input === 'q') { + process.stdout.write('\x1B[?25h'); // Make cursor visible + process.exit(0); + } + + if (key.return) { + (async () => { + await toggleDone(ditto, tasks[selected]); + })(); + } + }); + + return ( + + {tasks.map((task, i) => { + const done = task.done ? ' 🟢 ' : ' ⚪️ '; + const highlight = selected === i ? 'blue' : ''; + const cursor = selected === i ? '❯ ' : ' '; + return ( + + + {done} + + {cursor} + {task.title} + + + + ); + })} + + ); + }); + + const syncStatus = syncEnabled ? '🟢 Sync Active' : '🔴 Sync Inactive'; + const syncText = {syncStatus}; + + if (mode === LIST_MODE) { + return ( + + {syncText} + Done Title + + + ); + } + + if (mode === CREATE_MODE) { + return ( + + {syncText} + Create new Task + + + ); + } + + if (mode === EDIT_MODE) { + const selectedTask = tasks[selected]; + return ( + + {syncText} + Edit Task + + + ); + } }; // https://docs.ditto.live/sdk/latest/crud/update const toggleDone = async (ditto, task) => { - await ditto.store.execute('UPDATE tasks SET done=:done WHERE _id=:id', { - id: task._id, - done: !task.done, - }); + await ditto.store.execute('UPDATE tasks SET done=:done WHERE _id=:id', { + id: task._id, + done: !task.done, + }); }; // https://docs.ditto.live/sdk/latest/crud/create const createTask = async (ditto, title) => { - await ditto.store.execute('INSERT INTO tasks DOCUMENTS (:task)', { - task: { - title, - done: false, - deleted: false, - }, - }); + await ditto.store.execute('INSERT INTO tasks DOCUMENTS (:task)', { + task: { + title, + done: false, + deleted: false, + }, + }); }; // https://docs.ditto.live/sdk/latest/crud/delete#soft-delete-pattern const deleteTask = async (ditto, task) => { - await ditto.store.execute('UPDATE tasks SET deleted=true WHERE _id=:id', { - id: task._id, - }); + await ditto.store.execute('UPDATE tasks SET deleted=true WHERE _id=:id', { + id: task._id, + }); }; // https://docs.ditto.live/sdk/latest/crud/update const updateTask = async (ditto, id, title) => { - await ditto.store.execute('UPDATE tasks SET title=:title WHERE _id=:id', { - id, - title, - }); + await ditto.store.execute('UPDATE tasks SET title=:title WHERE _id=:id', { + id, + title, + }); }; diff --git a/javascript-tui/source/cli.js b/javascript-tui/source/cli.js index 2db1e48a8..035d33e12 100644 --- a/javascript-tui/source/cli.js +++ b/javascript-tui/source/cli.js @@ -1,15 +1,15 @@ #!/usr/bin/env node import React from 'react'; -import {render} from 'ink'; +import { render } from 'ink'; import meow from 'meow'; import App from './app.js'; import dotenv from 'dotenv'; -import {Ditto, DittoConfig, Authenticator} from '@dittolive/ditto'; -import {temporaryDirectory} from 'tempy'; +import { Ditto, DittoConfig, Authenticator } from '@dittolive/ditto'; +import { temporaryDirectory } from 'tempy'; -dotenv.config({path: '../.env'}); +dotenv.config({ path: '../.env' }); const cli = meow( - ` + ` Usage $ npm start -- 2>/dev/null @@ -19,23 +19,23 @@ const cli = meow( --auth-url [env: DITTO_AUTH_URL] The auth URL --websocket-url [env: DITTO_WEBSOCKET_URL] The websocket URL `, - { - importMeta: import.meta, - flags: { - appId: { - type: 'string', - }, - playgroundToken: { - type: 'string', - }, - authURL: { - type: 'string', - }, - websocketURL: { - type: 'string', - }, - }, - }, + { + importMeta: import.meta, + flags: { + appId: { + type: 'string', + }, + playgroundToken: { + type: 'string', + }, + authURL: { + type: 'string', + }, + websocketURL: { + type: 'string', + }, + }, + }, ); // We use a temporary directory to store Ditto's local database. This @@ -56,8 +56,8 @@ const websocketURL = cli.flags.websocketURL ?? process.env.DITTO_WEBSOCKET_URL; // Create a new Ditto instance with the DittoConfig // https://docs.ditto.live/sdk/latest/install-guides/nodejs#installing-the-demo-task-app const connectConfig = { - mode: 'server', - url: authURL, + mode: 'server', + url: authURL, }; const config = new DittoConfig(appID, connectConfig, tempdir); @@ -67,61 +67,61 @@ const ditto = await Ditto.open(config); // BLE and AWDL are disabled because they require macOS entitlements // that are only available to signed app bundles, not Node.js processes. // LAN (TCP + mDNS) provides P2P sync with peers on the local network. -ditto.updateTransportConfig(config => { - config.peerToPeer.bluetoothLE.isEnabled = false; - config.peerToPeer.awdl.isEnabled = false; - config.peerToPeer.lan.isEnabled = true; - config.peerToPeer.lan.isMdnsEnabled = true; - config.peerToPeer.lan.isMulticastEnabled = true; +ditto.updateTransportConfig((config) => { + config.peerToPeer.bluetoothLE.isEnabled = false; + config.peerToPeer.awdl.isEnabled = false; + config.peerToPeer.lan.isEnabled = true; + config.peerToPeer.lan.isMdnsEnabled = true; + config.peerToPeer.lan.isMulticastEnabled = true; }); // Set up authentication for server mode if (connectConfig.mode === 'server') { - await ditto.auth.setExpirationHandler( - async (dittoInstance, timeUntilExpiration) => { - console.log( - 'Authentication expiring soon, time until expiration:', - timeUntilExpiration, - ); + await ditto.auth.setExpirationHandler( + async (dittoInstance, timeUntilExpiration) => { + console.log( + 'Authentication expiring soon, time until expiration:', + timeUntilExpiration, + ); - if (dittoInstance.auth.loginSupported) { - const devProvider = Authenticator.DEVELOPMENT_PROVIDER; - const reLoginResult = await dittoInstance.auth.login( - token, - devProvider, - ); - if (reLoginResult.error) { - console.error('Re-authentication failed:', reLoginResult.error); - } else { - console.log( - 'Successfully re-authenticated with info:', - reLoginResult, - ); - } - } - }, - ); + if (dittoInstance.auth.loginSupported) { + const devProvider = Authenticator.DEVELOPMENT_PROVIDER; + const reLoginResult = await dittoInstance.auth.login( + token, + devProvider, + ); + if (reLoginResult.error) { + console.error('Re-authentication failed:', reLoginResult.error); + } else { + console.log( + 'Successfully re-authenticated with info:', + reLoginResult, + ); + } + } + }, + ); - if (ditto.auth.loginSupported) { - const devProvider = Authenticator.DEVELOPMENT_PROVIDER; - const loginResult = await ditto.auth.login(token, devProvider); - if (loginResult.error) { - console.error('Login failed:', loginResult.error); - } else { - console.log('Successfully logged in with info:', loginResult); - } - } + if (ditto.auth.loginSupported) { + const devProvider = Authenticator.DEVELOPMENT_PROVIDER; + const loginResult = await ditto.auth.login(token, devProvider); + if (loginResult.error) { + console.error('Login failed:', loginResult.error); + } else { + console.log('Successfully logged in with info:', loginResult); + } + } } ditto.sync.start(); -process.on('uncaughtException', err => { - console.error('Uncaught Exception:', err); +process.on('uncaughtException', (err) => { + console.error('Uncaught Exception:', err); }); -process.on('unhandledRejection', reason => { - console.error('Unhandled Rejection:', reason); +process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason); }); -const {waitUntilExit} = render(); +const { waitUntilExit } = render(); await waitUntilExit(); diff --git a/javascript-tui/tests/integration_test.js b/javascript-tui/tests/integration_test.js index 052448b56..1f2bfc9fd 100644 --- a/javascript-tui/tests/integration_test.js +++ b/javascript-tui/tests/integration_test.js @@ -1,12 +1,12 @@ #!/usr/bin/env node import React from 'react'; -import {render} from 'ink-testing-library'; +import { render } from 'ink-testing-library'; import dotenv from 'dotenv'; -import {Ditto, DittoConfig, Authenticator} from '@dittolive/ditto'; -import {temporaryDirectory} from 'tempy'; +import { Ditto, DittoConfig, Authenticator } from '@dittolive/ditto'; +import { temporaryDirectory } from 'tempy'; import App from '../dist/app.js'; -dotenv.config({path: '../.env'}); +dotenv.config({ path: '../.env' }); // Silence Ditto verbose logging for tests process.env.RUST_LOG = 'off'; @@ -15,83 +15,83 @@ const MAX_WAIT_ITERATIONS = 10; const POLL_INTERVAL_MS = 2000; async function createDittoInstance() { - const tempdir = temporaryDirectory(); - const appID = process.env.DITTO_APP_ID; - const token = process.env.DITTO_PLAYGROUND_TOKEN; - const authURL = process.env.DITTO_AUTH_URL; - const websocketURL = process.env.DITTO_WEBSOCKET_URL; - - const connectConfig = { - mode: 'server', - url: authURL, - }; - - const config = new DittoConfig(appID, connectConfig, tempdir); - const ditto = await Ditto.open(config); - - ditto.updateTransportConfig(config => { - config.connect.websocketURLs = [websocketURL]; - }); - - if (connectConfig.mode === 'server') { - await ditto.auth.setExpirationHandler( - async (dittoInstance, timeUntilExpiration) => { - if (dittoInstance.auth.loginSupported) { - const devProvider = Authenticator.DEVELOPMENT_PROVIDER; - await dittoInstance.auth.login(token, devProvider); - } - }, - ); - - if (ditto.auth.loginSupported) { - const devProvider = Authenticator.DEVELOPMENT_PROVIDER; - const loginResult = await ditto.auth.login(token, devProvider); - if (loginResult.error) { - throw new Error(`Login failed: ${loginResult.error}`); - } - } - } - - ditto.sync.start(); - return ditto; + const tempdir = temporaryDirectory(); + const appID = process.env.DITTO_APP_ID; + const token = process.env.DITTO_PLAYGROUND_TOKEN; + const authURL = process.env.DITTO_AUTH_URL; + const websocketURL = process.env.DITTO_WEBSOCKET_URL; + + const connectConfig = { + mode: 'server', + url: authURL, + }; + + const config = new DittoConfig(appID, connectConfig, tempdir); + const ditto = await Ditto.open(config); + + ditto.updateTransportConfig((config) => { + config.connect.websocketURLs = [websocketURL]; + }); + + if (connectConfig.mode === 'server') { + await ditto.auth.setExpirationHandler( + async (dittoInstance, timeUntilExpiration) => { + if (dittoInstance.auth.loginSupported) { + const devProvider = Authenticator.DEVELOPMENT_PROVIDER; + await dittoInstance.auth.login(token, devProvider); + } + }, + ); + + if (ditto.auth.loginSupported) { + const devProvider = Authenticator.DEVELOPMENT_PROVIDER; + const loginResult = await ditto.auth.login(token, devProvider); + if (loginResult.error) { + throw new Error(`Login failed: ${loginResult.error}`); + } + } + } + + ditto.sync.start(); + return ditto; } async function runIntegrationTest() { - let ditto; - try { - const expectedTitle = process.env.DITTO_CLOUD_TASK_TITLE; - - if (!expectedTitle || expectedTitle.trim() === '') { - throw new Error('Missing DITTO_CLOUD_TASK_TITLE environment variable'); - } - - ditto = await createDittoInstance(); - const {stdout} = render(React.createElement(App, {ditto})); - - for (let i = 1; i <= MAX_WAIT_ITERATIONS; i++) { - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); - - const frame = stdout.lastFrame(); - const hasSyncActive = frame.includes('🟢 Sync Active'); - const hasTask = frame.includes(expectedTitle); - - if (hasSyncActive && hasTask) { - console.log('SUCCESS: Integration test passed!'); - await ditto.close(); - process.exit(0); - } - } - - console.error('FAILURE: Integration test conditions not met'); - await ditto.close(); - process.exit(1); - } catch (error) { - console.error('Integration test error:', error); - if (ditto) { - await ditto.close(); - } - process.exit(1); - } + let ditto; + try { + const expectedTitle = process.env.DITTO_CLOUD_TASK_TITLE; + + if (!expectedTitle || expectedTitle.trim() === '') { + throw new Error('Missing DITTO_CLOUD_TASK_TITLE environment variable'); + } + + ditto = await createDittoInstance(); + const { stdout } = render(React.createElement(App, { ditto })); + + for (let i = 1; i <= MAX_WAIT_ITERATIONS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + + const frame = stdout.lastFrame(); + const hasSyncActive = frame.includes('🟢 Sync Active'); + const hasTask = frame.includes(expectedTitle); + + if (hasSyncActive && hasTask) { + console.log('SUCCESS: Integration test passed!'); + await ditto.close(); + process.exit(0); + } + } + + console.error('FAILURE: Integration test conditions not met'); + await ditto.close(); + process.exit(1); + } catch (error) { + console.error('Integration test error:', error); + if (ditto) { + await ditto.close(); + } + process.exit(1); + } } runIntegrationTest(); diff --git a/javascript-web/package.json b/javascript-web/package.json index 776208cd9..b354acdc6 100644 --- a/javascript-web/package.json +++ b/javascript-web/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@dittolive/ditto": "5.0.0", + "@dittolive/ditto": "5.0.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/kotlin-multiplatform/composeApp/build.gradle.kts b/kotlin-multiplatform/composeApp/build.gradle.kts index 1698953c4..992ef3024 100644 --- a/kotlin-multiplatform/composeApp/build.gradle.kts +++ b/kotlin-multiplatform/composeApp/build.gradle.kts @@ -45,7 +45,7 @@ kotlin { implementation(libs.koin.android) } commonMain.dependencies { - implementation("com.ditto:ditto-kotlin:5.0.0-preview.3") + implementation("com.ditto:ditto-kotlin:5.0.2") implementation(compose.runtime) implementation(compose.foundation) @@ -83,26 +83,26 @@ kotlin { implementation(libs.kotlinx.coroutines.swing) // This will include binaries for all the supported platforms and architectures - implementation("com.ditto:ditto-binaries:5.0.0-preview.3") + implementation("com.ditto:ditto-binaries:5.0.2") // To reduce your module artifact's size, consider including just the necessary platforms and architectures /* // macOS Apple Silicon - implementation("com.ditto:ditto-binaries:5.0.0-preview.3") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-macos-arm64") } } // Windows x86_64 - implementation("com.ditto:ditto-binaries:5.0.0-preview.3") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-windows-x64") } } // Linux x86_64 - implementation("com.ditto:ditto-binaries:5.0.0-preview.3") { + implementation("com.ditto:ditto-binaries:5.0.2") { capabilities { requireCapability("com.ditto:ditto-binaries-linux-x64") } diff --git a/kotlin-multiplatform/composeApp/src/androidMain/kotlin/com/ditto/quickstart/ditto/DittoManager.android.kt b/kotlin-multiplatform/composeApp/src/androidMain/kotlin/com/ditto/quickstart/ditto/DittoManager.android.kt index 2a30cd607..86320d31a 100644 --- a/kotlin-multiplatform/composeApp/src/androidMain/kotlin/com/ditto/quickstart/ditto/DittoManager.android.kt +++ b/kotlin-multiplatform/composeApp/src/androidMain/kotlin/com/ditto/quickstart/ditto/DittoManager.android.kt @@ -3,10 +3,8 @@ package com.ditto.quickstart.ditto import com.ditto.kotlin.Ditto import com.ditto.kotlin.DittoConfig import com.ditto.kotlin.DittoFactory -import com.ditto.quickstart.App actual fun createDitto(config: DittoConfig): Ditto = DittoFactory.create( - context = App.instance, config = config, ) diff --git a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/data/repository/DittoTaskRepository.kt b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/data/repository/DittoTaskRepository.kt index 464fce7d1..c888b1997 100644 --- a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/data/repository/DittoTaskRepository.kt +++ b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/data/repository/DittoTaskRepository.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -137,14 +136,14 @@ class DittoTaskRepository( } private suspend fun registerObserver() { - val observer = dittoManager.registerObserver(QUERY_SELECT_TASKS) + val observer = dittoManager.registerObserver(QUERY_SELECT_TASKS) { result -> + result.items.map { item -> item.toTask() } + } scope.launch { - observer - .map { result -> result.items.map { item -> item.toTask() } } - .collect { tasks -> - // Use database ordering (ORDER BY title ASC) - no client-side sorting needed - tasksMutableStateFlow.value = tasks - } + observer.collect { tasks -> + // Use database ordering (ORDER BY title ASC) - no client-side sorting needed + tasksMutableStateFlow.value = tasks + } } } } diff --git a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt index 49e137614..dd5817da8 100644 --- a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt +++ b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt @@ -4,12 +4,12 @@ import com.ditto.example.kotlin.quickstart.configuration.DittoSecretsConfigurati import com.ditto.kotlin.Ditto import com.ditto.kotlin.DittoAuthenticationProvider import com.ditto.kotlin.DittoConfig +import com.ditto.kotlin.DittoException import com.ditto.kotlin.DittoLog import com.ditto.kotlin.DittoLogLevel import com.ditto.kotlin.DittoLogger import com.ditto.kotlin.DittoQueryResult import com.ditto.kotlin.DittoSyncSubscription -import com.ditto.kotlin.error.DittoError import com.ditto.kotlin.serialization.DittoCborSerializable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -56,7 +56,7 @@ class DittoManager( createDitto( config = config ).apply { - auth?.setExpirationHandler { ditto, _ -> + auth?.expirationHandler = { ditto, _ -> // Authenticate when a token is expiring val clientInfo = ditto.auth?.login( token = secrets.DITTO_PLAYGROUND_TOKEN, @@ -87,7 +87,7 @@ class DittoManager( fun destroyDitto() { closeJob = scope.launch(Dispatchers.IO) { - getDitto()?.stopSync() + getDitto()?.sync?.stop() getDitto()?.close() ditto = null } @@ -95,21 +95,21 @@ class DittoManager( suspend fun startSync() { val ditto = getDitto() ?: return - ditto.startSync() + ditto.sync.start() } suspend fun stopSync() { - getDitto()?.stopSync() + getDitto()?.sync?.stop() } - suspend fun isSyncing() = getDitto()?.isSyncActive == true + suspend fun isSyncing() = getDitto()?.sync?.isActive == true suspend fun executeDql( query: String, parameters: DittoCborSerializable.Dictionary = DittoCborSerializable.Dictionary() ): DittoQueryResult? = try { - getDitto()?.store?.execute(query, parameters) - } catch (e: DittoError) { + getDitto()?.store?.executeRaw(query, parameters) + } catch (e: DittoException) { DittoLog.e("ExecuteDqlUse", "Error executing DQL query: ${e.message}") null } @@ -119,17 +119,19 @@ class DittoManager( arguments: DittoCborSerializable.Dictionary? = null ): DittoSyncSubscription? = try { getDitto()?.sync?.registerSubscription(query, arguments) - } catch (e: DittoError) { + } catch (e: DittoException) { DittoLog.e("RegisterSubscription", "Error registering subscription: ${e.message}") null } - suspend fun registerObserver( + suspend fun registerObserver( query: String, - arguments: DittoCborSerializable.Dictionary? = null - ): Flow = requireNotNull(getDitto()).store.observe( + arguments: DittoCborSerializable.Dictionary? = null, + transform: suspend (DittoQueryResult) -> T + ): Flow = requireNotNull(getDitto()).store.observe( query = query, - arguments = arguments + arguments = arguments, + transform = transform, ) private suspend fun waitForWorkInProgress() { diff --git a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/usecases/SetSyncStatusUseCase.kt b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/usecases/SetSyncStatusUseCase.kt index 861a39954..726d5e6e9 100644 --- a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/usecases/SetSyncStatusUseCase.kt +++ b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/usecases/SetSyncStatusUseCase.kt @@ -1,7 +1,7 @@ package com.ditto.quickstart.usecases +import com.ditto.kotlin.DittoException import com.ditto.kotlin.DittoLog -import com.ditto.kotlin.error.DittoError import com.ditto.quickstart.data.repository.PreferenceRepository import com.ditto.quickstart.ditto.DittoManager @@ -25,7 +25,7 @@ class SetSyncStatusUseCase( private suspend fun enableSync(): Boolean { try { dittoManager.startSync() - } catch (e: DittoError) { + } catch (e: DittoException) { DittoLog.e(TAG, "Failed to start sync: $e") return false } @@ -43,7 +43,7 @@ class SetSyncStatusUseCase( private suspend fun disableSync(): Boolean { try { dittoManager.stopSync() - } catch (e: DittoError) { + } catch (e: DittoException) { DittoLog.e(TAG, "Failed to stop sync: $e") return false } diff --git a/kotlin-multiplatform/detekt.yml b/kotlin-multiplatform/detekt.yml index 9f029c6e7..d2ecdbdc4 100644 --- a/kotlin-multiplatform/detekt.yml +++ b/kotlin-multiplatform/detekt.yml @@ -142,14 +142,30 @@ complexity: active: false StringLiteralDuplication: active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] threshold: 11 thresholdInFiles: 11 thresholdInClasses: 11 @@ -225,7 +241,15 @@ exceptions: - 'toString' InstanceOfCheckForException: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] NotImplementedDeclaration: active: false ObjectExtendsThrowable: @@ -251,7 +275,15 @@ exceptions: active: false ThrowingExceptionsWithoutMessageOrCause: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Exception' @@ -266,7 +298,15 @@ exceptions: active: true TooGenericExceptionCaught: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' @@ -312,7 +352,15 @@ naming: minimumFunctionNameLength: 3 FunctionNaming: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] functionPattern: '[a-z][a-zA-Z0-9]*' excludeClassPattern: '$^' ignoreAnnotated: ['Composable'] @@ -373,10 +421,26 @@ performance: threshold: 3 ForEachOnRange: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] SpreadOperator: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] UnnecessaryTemporaryInstantiation: active: true @@ -441,7 +505,15 @@ potential-bugs: active: true LateinitUsage: active: false - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: active: true @@ -464,7 +536,15 @@ potential-bugs: active: true UnsafeCallOnNullableType: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] UnsafeCast: active: true UnusedUnaryOperator: @@ -502,7 +582,15 @@ style: active: false ExplicitItLambdaParameter: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] ExpressionBodySyntax: active: false includeLineWrapping: false @@ -561,7 +649,16 @@ style: maxJumpCount: 1 MagicNumber: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + '**/*.kts', + ] ignoreNumbers: - '-1' - '0' @@ -712,6 +809,14 @@ style: ignoreLateinitVar: false WildcardImport: active: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludes: + [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + ] excludeImports: - - 'java.util.*' \ No newline at end of file + - 'java.util.*' diff --git a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml index 31e7fe84f..a1302d5c5 100644 --- a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml +++ b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml @@ -1,6 +1,6 @@ appId: com.anonymous.reactnativeexpo tags: - - "smoke" + - 'smoke' --- # Simple test: Launch app and verify specific task exists using testID # Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-android.yaml @@ -11,9 +11,9 @@ tags: # Handle permission dialog if it appears - runFlow: when: - visible: "Allow" + visible: 'Allow' commands: - - tapOn: "Allow" + - tapOn: 'Allow' # Wait for app to load - waitForAnimationToEnd @@ -21,7 +21,7 @@ tags: # Debug: Try text selector first to see if task exists - scrollUntilVisible: element: - text: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" + text: '${MAESTRO_DITTO_CLOUD_TASK_TITLE}' direction: DOWN timeout: 30000 speed: 40 @@ -29,8 +29,8 @@ tags: # Debug: Assert with text selector - assertVisible: - text: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" + text: '${MAESTRO_DITTO_CLOUD_TASK_TITLE}' # Debug: Also try testID selector - assertVisible: - id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file + id: '${MAESTRO_DITTO_CLOUD_TASK_TITLE}' diff --git a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml index 8314a3dc7..b794b1962 100644 --- a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml +++ b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml @@ -1,6 +1,6 @@ appId: com.anonymous.reactnativeexpo tags: - - "smoke" + - 'smoke' --- # Simple test: Launch app and verify specific task exists using testID # Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-ios.yaml @@ -11,16 +11,16 @@ tags: # Handle location permission dialog if it appears - runFlow: when: - visible: "Allow" + visible: 'Allow' commands: - - tapOn: "Allow" + - tapOn: 'Allow' # Handle Bluetooth permission dialog if it appears - runFlow: when: - visible: "OK" + visible: 'OK' commands: - - tapOn: "OK" + - tapOn: 'OK' # Wait for app to load - waitForAnimationToEnd @@ -28,7 +28,7 @@ tags: # Scroll until the task is visible using testID - scrollUntilVisible: element: - id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" + id: '${MAESTRO_DITTO_CLOUD_TASK_TITLE}' direction: DOWN timeout: 30000 speed: 40 @@ -36,4 +36,4 @@ tags: # Final assertion - task must be visible using id selector - assertVisible: - id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file + id: '${MAESTRO_DITTO_CLOUD_TASK_TITLE}' diff --git a/react-native-expo/App.tsx b/react-native-expo/App.tsx index 8172582c9..7129b6c12 100644 --- a/react-native-expo/App.tsx +++ b/react-native-expo/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef } from 'react'; import { Text, StyleSheet, @@ -7,8 +7,8 @@ import { View, FlatList, Button, -} from "react-native"; -import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { Authenticator, Ditto, @@ -17,20 +17,20 @@ import { init, StoreObserver, SyncSubscription, -} from "@dittolive/ditto"; +} from '@dittolive/ditto'; import { DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, DITTO_WEBSOCKET_URL, -} from "@env"; +} from '@env'; -import Fab from "./components/Fab"; -import NewTaskModal from "./components/NewTaskModal"; -import DittoInfo from "./components/DittoInfo"; -import DittoSync from "./components/DittoSync"; -import TaskDone from "./components/TaskDone"; -import EditTaskModal from "./components/EditTaskModal"; +import Fab from './components/Fab'; +import NewTaskModal from './components/NewTaskModal'; +import DittoInfo from './components/DittoInfo'; +import DittoSync from './components/DittoSync'; +import TaskDone from './components/TaskDone'; +import EditTaskModal from './components/EditTaskModal'; type Task = { id: string; @@ -39,7 +39,6 @@ type Task = { deleted: boolean; }; - async function requestPermissions() { const permissions = [ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, @@ -50,7 +49,7 @@ async function requestPermissions() { const granted = await PermissionsAndroid.requestMultiple(permissions); return Object.values(granted).every( - (result) => result === PermissionsAndroid.RESULTS.GRANTED + (result) => result === PermissionsAndroid.RESULTS.GRANTED, ); } @@ -77,10 +76,10 @@ const App = () => { }; const createTask = async (title: string) => { - if (title === "") { + if (title === '') { return; } - await ditto.current?.store.execute("INSERT INTO tasks DOCUMENTS (:task)", { + await ditto.current?.store.execute('INSERT INTO tasks DOCUMENTS (:task)', { task: { title, done: false, @@ -91,30 +90,30 @@ const App = () => { const toggleTask = async (task: Task) => { await ditto.current?.store.execute( - "UPDATE tasks SET done=:done WHERE _id=:id", + 'UPDATE tasks SET done=:done WHERE _id=:id', { id: task.id, done: !task.done, - } + }, ); }; const deleteTask = async (task: Task) => { await ditto.current?.store.execute( - "UPDATE tasks SET deleted=true WHERE _id=:id", + 'UPDATE tasks SET deleted=true WHERE _id=:id', { id: task.id, - } + }, ); }; const updateTaskTitle = async (taskId: string, newTitle: string) => { await ditto.current?.store.execute( - "UPDATE tasks SET title=:title WHERE _id=:id", + 'UPDATE tasks SET title=:title WHERE _id=:id', { id: taskId, title: newTitle, - } + }, ); }; @@ -131,7 +130,11 @@ const App = () => { url: DITTO_AUTH_URL, }; - const config = new DittoConfig(databaseId, connectConfig, 'custom-folder'); + const config = new DittoConfig( + databaseId, + connectConfig, + 'custom-folder', + ); ditto.current = await Ditto.open(config); @@ -141,26 +144,40 @@ const App = () => { }); if (connectConfig.mode === 'server') { - await ditto.current.auth.setExpirationHandler(async (dittoInstance, timeUntilExpiration) => { - console.log('Authentication expiring soon, time until expiration:', timeUntilExpiration); - - if (dittoInstance.auth.loginSupported) { - const devProvider = Authenticator.DEVELOPMENT_PROVIDER; - const reLoginResult = await dittoInstance.auth.login(playgroundToken, devProvider); - if (reLoginResult.error) { - console.error('Re-authentication failed:', reLoginResult.error); - } else { - console.log('Successfully re-authenticated with info:', reLoginResult); + await ditto.current.auth.setExpirationHandler( + async (dittoInstance, timeUntilExpiration) => { + console.log( + 'Authentication expiring soon, time until expiration:', + timeUntilExpiration, + ); + + if (dittoInstance.auth.loginSupported) { + const devProvider = Authenticator.DEVELOPMENT_PROVIDER; + const reLoginResult = await dittoInstance.auth.login( + playgroundToken, + devProvider, + ); + if (reLoginResult.error) { + console.error('Re-authentication failed:', reLoginResult.error); + } else { + console.log( + 'Successfully re-authenticated with info:', + reLoginResult, + ); + } } - } - }); + }, + ); if (ditto.current.auth.loginSupported) { // Use the development provider constant from Ditto const devProvider = Authenticator.DEVELOPMENT_PROVIDER; console.log('Using development provider:', devProvider); - const loginResult = await ditto.current.auth.login(playgroundToken, devProvider); + const loginResult = await ditto.current.auth.login( + playgroundToken, + devProvider, + ); if (loginResult.error) { console.error('Login failed:', loginResult.error); } else { @@ -171,20 +188,27 @@ const App = () => { ditto.current.sync.start(); - await ditto.current.store.execute('ALTER SYSTEM SET DQL_STRICT_MODE = false'); - - taskSubscription.current = ditto.current.sync.registerSubscription('SELECT * FROM tasks'); - - taskObserver.current = ditto.current.store.registerObserver('SELECT * FROM tasks WHERE NOT deleted ORDER BY title ASC', response => { - const fetchedTasks: Task[] = response.items.map(doc => ({ - id: doc.value._id, - title: doc.value.title as string, - done: doc.value.done, - deleted: doc.value.deleted, - })); - - setTasks(fetchedTasks); - }); + await ditto.current.store.execute( + 'ALTER SYSTEM SET DQL_STRICT_MODE = false', + ); + + taskSubscription.current = ditto.current.sync.registerSubscription( + 'SELECT * FROM tasks', + ); + + taskObserver.current = ditto.current.store.registerObserver( + 'SELECT * FROM tasks WHERE NOT deleted ORDER BY title ASC', + (response) => { + const fetchedTasks: Task[] = response.items.map((doc) => ({ + id: doc.value._id, + title: doc.value.title as string, + done: doc.value.done, + deleted: doc.value.deleted, + })); + + setTasks(fetchedTasks); + }, + ); } catch (error) { console.error('Error syncing tasks:', error); } @@ -193,7 +217,7 @@ const App = () => { useEffect(() => { (async () => { const granted = - Platform.OS === "android" ? await requestPermissions() : true; + Platform.OS === 'android' ? await requestPermissions() : true; setHasPermissions(granted); initDitto(); @@ -203,7 +227,11 @@ const App = () => { const renderItem = ({ item }: { item: Task }) => ( toggleTask(item)} /> - setEditingTask(item)} testID={item.title}> + setEditingTask(item)} + testID={item.title} + > {item.title} @@ -219,40 +247,41 @@ const App = () => { return ( - {!hasPermissions && ( - - - ⚠️ Limited functionality: Grant Bluetooth & WiFi permissions for peer-to-peer sync - - - )} - - - setModalVisible(true)} /> - { - createTask(task); - setModalVisible(false); - }} - onClose={() => setModalVisible(false)} - /> - setEditingTask(null)} - onSubmit={(taskId, newTitle) => { - updateTaskTitle(taskId, newTitle); - setEditingTask(null); - }} - onClose={() => setEditingTask(null)} - /> - item.id} - /> + {!hasPermissions && ( + + + ⚠️ Limited functionality: Grant Bluetooth & WiFi permissions for + peer-to-peer sync + + + )} + + + setModalVisible(true)} /> + { + createTask(task); + setModalVisible(false); + }} + onClose={() => setModalVisible(false)} + /> + setEditingTask(null)} + onSubmit={(taskId, newTitle) => { + updateTaskTitle(taskId, newTitle); + setEditingTask(null); + }} + onClose={() => setEditingTask(null)} + /> + item.id} + /> ); @@ -260,9 +289,9 @@ const App = () => { const styles = StyleSheet.create({ container: { - height: "100%", + height: '100%', padding: 20, - backgroundColor: "#fff", + backgroundColor: '#fff', }, listContainer: { gap: 5, @@ -270,33 +299,33 @@ const styles = StyleSheet.create({ taskContainer: { flex: 1, gap: 5, - flexDirection: "row", + flexDirection: 'row', paddingVertical: 10, paddingHorizontal: 20, }, taskTitle: { fontSize: 20, - alignSelf: "center", + alignSelf: 'center', flexGrow: 1, flexShrink: 1, }, taskButton: { flexShrink: 1, - alignSelf: "center", + alignSelf: 'center', }, permissionBanner: { - backgroundColor: "#FEF3C7", + backgroundColor: '#FEF3C7', borderWidth: 1, - borderColor: "#D97706", + borderColor: '#D97706', borderRadius: 8, padding: 12, marginBottom: 16, }, permissionText: { - color: "#92400E", + color: '#92400E', fontSize: 14, - textAlign: "center", - fontWeight: "500", + textAlign: 'center', + fontWeight: '500', }, }); diff --git a/react-native-expo/android/gradle.properties b/react-native-expo/android/gradle.properties index 66b8dac29..b435b9ba6 100644 --- a/react-native-expo/android/gradle.properties +++ b/react-native-expo/android/gradle.properties @@ -28,7 +28,14 @@ android.enablePngCrunchInReleaseBuilds=true # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 -reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 +# +# x86 and x86_64 are omitted here: building all four ABIs causes +# react-native-reanimated's prefab .so files to exhaust disk on CI runners +# before bundleReleaseAar can write the final ZIP. Real devices (and +# BrowserStack device farms) are all ARM, so dropping x86/x86_64 has no +# impact on test coverage. Emulator testing is handled by developer +# workstations, not CI. +reactNativeArchitectures=armeabi-v7a,arm64-v8a # Use this property to enable support to the new architecture. # This will allow you to use TurboModules and the Fabric render in diff --git a/react-native-expo/babel.config.js b/react-native-expo/babel.config.js index dd4e2af52..14df38c6e 100644 --- a/react-native-expo/babel.config.js +++ b/react-native-expo/babel.config.js @@ -1,13 +1,13 @@ module.exports = function (api) { api.cache(false); return { - presets: ["babel-preset-expo"], + presets: ['babel-preset-expo'], plugins: [ [ - "module:react-native-dotenv", + 'module:react-native-dotenv', { - moduleName: "@env", - path: "../.env", + moduleName: '@env', + path: '../.env', safe: false, allowUndefined: true, verbose: false, diff --git a/react-native-expo/components/DittoInfo.tsx b/react-native-expo/components/DittoInfo.tsx index 4d578cf18..5f31b60fa 100644 --- a/react-native-expo/components/DittoInfo.tsx +++ b/react-native-expo/components/DittoInfo.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; type Props = { - appId: string, - token: string, -} + appId: string; + token: string; +}; const DittoInfo: React.FC = ({ appId, token }) => { return ( diff --git a/react-native-expo/components/EditTaskModal.tsx b/react-native-expo/components/EditTaskModal.tsx index 2806b8ee6..f3d54e982 100644 --- a/react-native-expo/components/EditTaskModal.tsx +++ b/react-native-expo/components/EditTaskModal.tsx @@ -1,15 +1,28 @@ import React, { useState, useEffect } from 'react'; -import { Button, Modal, ModalProps, StyleSheet, Text, TextInput, View } from 'react-native'; +import { + Button, + Modal, + ModalProps, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; type EditTaskModalProps = { - task: { id: string, title: string } | null, - onSubmit: (taskId: string, newTitle: string) => void, - onClose?: () => void, -} + task: { id: string; title: string } | null; + onSubmit: (taskId: string, newTitle: string) => void; + onClose?: () => void; +}; type Props = EditTaskModalProps & ModalProps; -const EditTaskModal: React.FC = ({ task, onSubmit, onClose, ...props }) => { +const EditTaskModal: React.FC = ({ + task, + onSubmit, + onClose, + ...props +}) => { const [input, setInput] = useState(''); useEffect(() => { diff --git a/react-native-expo/components/Fab.tsx b/react-native-expo/components/Fab.tsx index 12176b0b1..6d8fd997b 100644 --- a/react-native-expo/components/Fab.tsx +++ b/react-native-expo/components/Fab.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, TouchableOpacityProps } from 'react-native'; +import { + StyleSheet, + Text, + TouchableOpacity, + TouchableOpacityProps, +} from 'react-native'; const Fab = (props: TouchableOpacityProps) => { return ( diff --git a/react-native-expo/components/NewTaskModal.tsx b/react-native-expo/components/NewTaskModal.tsx index acaf1bf6c..f8f764584 100644 --- a/react-native-expo/components/NewTaskModal.tsx +++ b/react-native-expo/components/NewTaskModal.tsx @@ -1,10 +1,18 @@ import React, { useState } from 'react'; -import { Button, Modal, ModalProps, StyleSheet, Text, TextInput, View } from 'react-native'; +import { + Button, + Modal, + ModalProps, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; type NewTaskModalProps = { - onSubmit: (taskName: string) => void, - onClose?: () => void, -} + onSubmit: (taskName: string) => void; + onClose?: () => void; +}; type Props = NewTaskModalProps & ModalProps; @@ -23,7 +31,11 @@ const NewTaskModal: React.FC = ({ onSubmit, onClose, ...props }) => { New Task - +