From 7dbd62bed9aec92e36b189d399631cde5efa0c84 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Jan 2026 12:19:55 +0100 Subject: [PATCH 01/12] feat: add android directory --- android/build.gradle | 43 ++++++++++++++++ android/src/main/AndroidManifest.xml | 2 + android/src/main/java/voltra/VoltraModule.kt | 50 +++++++++++++++++++ .../src/main/java/voltra/VoltraModuleView.kt | 30 +++++++++++ expo-module.config.json | 3 ++ 5 files changed, 128 insertions(+) create mode 100644 android/build.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/java/voltra/VoltraModule.kt create mode 100644 android/src/main/java/voltra/VoltraModuleView.kt diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..ea8aa83 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.library' + +group = 'voltra' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 36) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 24) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "voltra" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdae66c --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/src/main/java/voltra/VoltraModule.kt b/android/src/main/java/voltra/VoltraModule.kt new file mode 100644 index 0000000..3ef12a8 --- /dev/null +++ b/android/src/main/java/voltra/VoltraModule.kt @@ -0,0 +1,50 @@ +package voltra + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.net.URL + +class VoltraModule : Module() { + // Each module class must implement the definition function. The definition consists of components + // that describes the module's functionality and behavior. + // See https://docs.expo.dev/modules/module-api for more details about available components. + override fun definition() = ModuleDefinition { + // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. + // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. + // The module will be accessible from `requireNativeModule('VoltraModule')` in JavaScript. + Name("VoltraModule") + + // Defines constant property on the module. + Constant("PI") { + Math.PI + } + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello world! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + // Send an event to JavaScript. + sendEvent("onChange", mapOf( + "value" to value + )) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of + // the view definition: Prop, Events. + View(VoltraModuleView::class) { + // Defines a setter for the `url` prop. + Prop("url") { view: VoltraModuleView, url: URL -> + view.webView.loadUrl(url.toString()) + } + // Defines an event that the view can send to JavaScript. + Events("onLoad") + } + } +} diff --git a/android/src/main/java/voltra/VoltraModuleView.kt b/android/src/main/java/voltra/VoltraModuleView.kt new file mode 100644 index 0000000..a4a430a --- /dev/null +++ b/android/src/main/java/voltra/VoltraModuleView.kt @@ -0,0 +1,30 @@ +package voltra + +import android.content.Context +import android.webkit.WebView +import android.webkit.WebViewClient +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class VoltraModuleView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + // Creates and initializes an event dispatcher for the `onLoad` event. + // The name of the event is inferred from the value and needs to match the event name defined in the module. + private val onLoad by EventDispatcher() + + // Defines a WebView that will be used as the root subview. + internal val webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. + onLoad(mapOf("url" to url)) + } + } + } + + init { + // Adds the WebView to the view hierarchy. + addView(webView) + } +} diff --git a/expo-module.config.json b/expo-module.config.json index 2c32723..3125a0f 100644 --- a/expo-module.config.json +++ b/expo-module.config.json @@ -2,5 +2,8 @@ "platforms": ["apple", "web"], "apple": { "modules": ["VoltraModule"] + }, + "android": { + "modules": ["voltra.VoltraModule"] } } From c85edf5ea27158aa07e719ae31376933f13eaa12 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Jan 2026 12:33:05 +0100 Subject: [PATCH 02/12] feat: add support for Android widgets --- .editorconfig | 13 + .github/workflows/ci.yml | 16 + .gitignore | 14 + .npmignore | 1 + android/build.gradle | 43 +- android/src/main/AndroidManifest.xml | 17 +- .../src/main/java/voltra/ComponentRegistry.kt | 53 + android/src/main/java/voltra/VoltraModule.kt | 316 +- .../src/main/java/voltra/VoltraModuleView.kt | 30 - .../java/voltra/VoltraNotificationManager.kt | 157 + android/src/main/java/voltra/VoltraRN.kt | 192 + .../main/java/voltra/events/VoltraEvent.kt | 25 + .../main/java/voltra/events/VoltraEventBus.kt | 198 + .../main/java/voltra/generated/ShortNames.kt | 165 + .../main/java/voltra/glance/GlanceFactory.kt | 20 + .../voltra/glance/RemoteViewsGenerator.kt | 121 + .../src/main/java/voltra/glance/StyleUtils.kt | 122 + .../java/voltra/glance/VoltraRenderContext.kt | 15 + .../glance/renderers/ButtonRenderers.kt | 213 + .../glance/renderers/ComplexRenderers.kt | 173 + .../voltra/glance/renderers/InputRenderers.kt | 180 + .../glance/renderers/LayoutRenderers.kt | 241 + .../glance/renderers/LazyListRenderers.kt | 137 + .../glance/renderers/ProgressRenderers.kt | 109 + .../voltra/glance/renderers/RenderCommon.kt | 209 + .../glance/renderers/TextAndImageRenderers.kt | 108 + .../java/voltra/images/VoltraImageManager.kt | 123 + .../main/java/voltra/models/VoltraPayload.kt | 53 + .../models/parameters/AndroidBoxParameters.kt | 20 + .../parameters/AndroidButtonParameters.kt | 20 + .../parameters/AndroidCheckBoxParameters.kt | 26 + .../AndroidCircleIconButtonParameters.kt | 32 + ...roidCircularProgressIndicatorParameters.kt | 20 + .../parameters/AndroidColumnParameters.kt | 23 + .../AndroidFilledButtonParameters.kt | 35 + .../parameters/AndroidImageParameters.kt | 23 + .../parameters/AndroidLazyColumnParameters.kt | 20 + .../AndroidLazyVerticalGridParameters.kt | 20 + ...ndroidLinearProgressIndicatorParameters.kt | 23 + .../AndroidOutlineButtonParameters.kt | 32 + .../AndroidRadioButtonParameters.kt | 26 + .../models/parameters/AndroidRowParameters.kt | 23 + .../parameters/AndroidScaffoldParameters.kt | 23 + .../parameters/AndroidSpacerParameters.kt | 20 + .../AndroidSquareIconButtonParameters.kt | 32 + .../parameters/AndroidSwitchParameters.kt | 26 + .../parameters/AndroidTextParameters.kt | 29 + .../parameters/AndroidTitleBarParameters.kt | 26 + .../java/voltra/parsing/VoltraDecompressor.kt | 58 + .../voltra/parsing/VoltraNodeDeserializer.kt | 39 + .../voltra/parsing/VoltraPayloadParser.kt | 32 + .../java/voltra/payload/ComponentTypeID.kt | 43 + .../main/java/voltra/styling/JSColorParser.kt | 249 + .../main/java/voltra/styling/JSStyleParser.kt | 305 + .../java/voltra/styling/StyleConverter.kt | 237 + .../java/voltra/styling/StyleModifiers.kt | 303 + .../java/voltra/styling/StyleStructures.kt | 207 + .../java/voltra/widget/VoltraGlanceWidget.kt | 171 + .../java/voltra/widget/VoltraWidgetManager.kt | 314 + .../voltra/widget/VoltraWidgetReceiver.kt | 28 + .../src/main/res/xml/voltra_file_paths.xml | 4 + data/components.json | 467 +- example/app.json | 51 +- example/app/_layout.tsx | 41 +- example/app/android-widgets.tsx | 5 + .../app/android-widgets/image-preloading.tsx | 5 + example/app/android-widgets/interactive.tsx | 5 + example/app/android-widgets/pin.tsx | 5 + example/app/android-widgets/preview.tsx | 5 + example/app/index.tsx | 4 +- example/assets/voltra-android/voltra-logo.svg | 4 + example/components/BackgroundWrapper.tsx | 6 +- example/hooks/useVoltraEvents.ts | 7 + .../android/AndroidImagePreloadingScreen.tsx | 223 + .../AndroidInteractiveWidgetScreen.tsx | 318 + .../screens/android/AndroidPreviewScreen.tsx | 229 + example/screens/android/AndroidScreen.tsx | 90 + .../android/AndroidWidgetPinScreen.tsx | 261 + .../BasicAndroidLiveUpdate.tsx | 189 + .../live-activities/LiveActivitiesScreen.tsx | 25 +- .../testing-grounds/ImagePreloadingScreen.tsx | 12 +- .../testing-grounds/TestingGroundsScreen.tsx | 7 +- .../components/ComponentsScreen.tsx | 5 +- .../testing-grounds/styling/StylingScreen.tsx | 5 +- .../weather/WeatherTestingScreen.tsx | 7 +- example/widgets/AndroidVoltraWidget.tsx | 20 + .../widgets/android-voltra-widget-initial.tsx | 10 + example/widgets/updateAndroidVoltraWidget.tsx | 24 + expo-module.config.json | 2 +- generator/generate-types.ts | 27 +- generator/generators/component-ids.ts | 72 +- generator/generators/kotlin-parameters.ts | 90 + generator/generators/short-names.ts | 47 +- generator/generators/swift-parameters.ts | 6 +- generator/types.ts | 1 + ios/shared/ComponentTypeID.swift | 172 +- ios/shared/ShortNames.swift | 281 +- .../AndroidCheckBoxParameters.swift | 29 + .../AndroidRadioButtonParameters.swift | 29 + .../Parameters/AndroidSwitchParameters.swift | 29 + .../Parameters/ButtonParameters.swift | 4 +- .../CircularProgressViewParameters.swift | 92 +- .../Parameters/DividerParameters.swift | 4 +- .../Parameters/FilledButtonParameters.swift | 29 + .../Parameters/GaugeParameters.swift | 90 +- .../Parameters/GlassContainerParameters.swift | 4 +- .../Parameters/GroupBoxParameters.swift | 4 +- .../Parameters/HStackParameters.swift | 26 +- .../Parameters/ImageParameters.swift | 28 +- .../Parameters/LabelParameters.swift | 8 +- .../Parameters/LinearGradientParameters.swift | 20 +- .../LinearProgressViewParameters.swift | 90 +- .../Generated/Parameters/MaskParameters.swift | 4 +- .../Parameters/SpacerParameters.swift | 4 +- .../Parameters/SymbolParameters.swift | 92 +- .../Generated/Parameters/TextParameters.swift | 8 +- .../Parameters/TimerParameters.swift | 82 +- .../Parameters/ToggleParameters.swift | 18 +- .../Parameters/VStackParameters.swift | 26 +- .../Parameters/ZStackParameters.swift | 4 +- package-lock.json | 129 +- package.json | 17 +- plugin/src/constants/index.ts | 2 +- plugin/src/constants/paths.ts | 3 + .../features/android/files/assets/images.ts | 160 + .../features/android/files/assets/index.ts | 62 + plugin/src/features/android/files/index.ts | 65 + .../features/android/files/initialStates.ts | 54 + .../features/android/files/kotlin/index.ts | 36 + .../android/files/kotlin/widgetReceiver.ts | 25 + .../src/features/android/files/xml/index.ts | 48 + .../android/files/xml/placeholderLayout.ts | 22 + .../android/files/xml/stringResources.ts | 20 + .../features/android/files/xml/widgetInfo.ts | 38 + plugin/src/features/android/index.ts | 28 + plugin/src/features/android/manifest/index.ts | 71 + plugin/src/features/ios/files/swift/index.ts | 5 +- plugin/src/index.ts | 19 +- plugin/src/types/index.ts | 9 +- plugin/src/types/plugin.ts | 82 +- .../ios/files/swift => utils}/prerender.ts | 32 +- .../src/validation/validateAndroidWidget.ts | 63 + plugin/src/validation/validateProps.ts | 27 +- pnpm-lock.yaml | 10302 ++++++++++++++++ src/VoltraModule.ts | 61 + src/android/client.ts | 44 + src/android/components/VoltraView.tsx | 76 + .../components/VoltraWidgetPreview.tsx | 49 + src/android/index.ts | 24 + src/android/jsx/Box.tsx | 5 + src/android/jsx/Button.tsx | 5 + src/android/jsx/CheckBox.tsx | 5 + src/android/jsx/CircleIconButton.tsx | 14 + src/android/jsx/CircularProgressIndicator.tsx | 6 + src/android/jsx/Column.tsx | 5 + src/android/jsx/FilledButton.tsx | 14 + src/android/jsx/Image.tsx | 21 + src/android/jsx/LazyColumn.tsx | 5 + src/android/jsx/LazyVerticalGrid.tsx | 5 + src/android/jsx/LinearProgressIndicator.tsx | 5 + src/android/jsx/OutlineButton.tsx | 14 + src/android/jsx/RadioButton.tsx | 5 + src/android/jsx/Row.tsx | 5 + src/android/jsx/Scaffold.tsx | 5 + src/android/jsx/Spacer.tsx | 5 + src/android/jsx/SquareIconButton.tsx | 14 + src/android/jsx/Switch.tsx | 5 + src/android/jsx/Text.tsx | 10 + src/android/jsx/TitleBar.tsx | 14 + src/android/jsx/baseProps.tsx | 10 + src/android/jsx/index.ts | 1 + src/android/jsx/primitives.ts | 20 + src/android/jsx/props/Box.ts | 15 + src/android/jsx/props/Button.ts | 6 + src/android/jsx/props/CheckBox.ts | 17 + src/android/jsx/props/CircleIconButton.ts | 15 + .../jsx/props/CircularProgressIndicator.ts | 12 + src/android/jsx/props/Column.ts | 8 + src/android/jsx/props/FilledButton.ts | 17 + src/android/jsx/props/Image.ts | 20 + src/android/jsx/props/LazyColumn.ts | 6 + src/android/jsx/props/LazyVerticalGrid.ts | 12 + .../jsx/props/LinearProgressIndicator.ts | 10 + src/android/jsx/props/OutlineButton.ts | 15 + src/android/jsx/props/RadioButton.ts | 19 + src/android/jsx/props/Row.ts | 8 + src/android/jsx/props/Scaffold.ts | 8 + src/android/jsx/props/Spacer.ts | 6 + src/android/jsx/props/SquareIconButton.ts | 15 + src/android/jsx/props/Switch.ts | 21 + src/android/jsx/props/Text.ts | 6 + src/android/jsx/props/TitleBar.ts | 15 + src/android/live-update/api.ts | 244 + src/android/live-update/renderer.ts | 51 + src/android/live-update/types.ts | 115 + src/android/payload/component-ids.ts | 82 + src/android/preload.ts | 72 + src/android/server.ts | 2 + src/android/styles/types.ts | 119 + src/android/widgets/api.ts | 150 + src/android/widgets/index.ts | 16 + src/android/widgets/renderer.ts | 60 + src/android/widgets/types.ts | 45 + src/events.ts | 22 +- src/index.ts | 2 + src/jsx/props/AndroidBox.ts | 11 + src/jsx/props/AndroidButton.ts | 11 + src/jsx/props/AndroidCheckBox.ts | 15 + src/jsx/props/AndroidCircleIconButton.ts | 19 + .../props/AndroidCircularProgressIndicator.ts | 11 + src/jsx/props/AndroidColumn.ts | 13 + src/jsx/props/AndroidFilledButton.ts | 21 + src/jsx/props/AndroidImage.ts | 13 + src/jsx/props/AndroidLazyColumn.ts | 11 + src/jsx/props/AndroidLazyVerticalGrid.ts | 8 + .../props/AndroidLinearProgressIndicator.ts | 13 + src/jsx/props/AndroidOutlineButton.ts | 19 + src/jsx/props/AndroidRadioButton.ts | 15 + src/jsx/props/AndroidRow.ts | 13 + src/jsx/props/AndroidScaffold.ts | 13 + src/jsx/props/AndroidSpacer.ts | 8 + src/jsx/props/AndroidSquareIconButton.ts | 19 + src/jsx/props/AndroidSwitch.ts | 15 + src/jsx/props/AndroidText.ts | 17 + src/jsx/props/AndroidTitleBar.ts | 15 + src/jsx/props/Button.ts | 1 + src/jsx/props/CircularProgressView.ts | 1 + src/jsx/props/Divider.ts | 1 + src/jsx/props/FilledButton.ts | 21 + src/jsx/props/Gauge.ts | 8 +- src/jsx/props/GlassContainer.ts | 1 + src/jsx/props/HStack.ts | 1 + src/jsx/props/Image.ts | 7 +- src/jsx/props/Label.ts | 1 + src/jsx/props/LegacyAndroidCheckBox.ts | 13 + src/jsx/props/LegacyAndroidRadioButton.ts | 13 + src/jsx/props/LegacyAndroidSwitch.ts | 13 + src/jsx/props/LegacyFilledButton.ts | 13 + src/jsx/props/LegacyImage.ts | 13 + src/jsx/props/LinearGradient.ts | 1 + src/jsx/props/Spacer.ts | 1 + src/jsx/props/Symbol.ts | 13 +- src/jsx/props/Text.ts | 1 + src/jsx/props/Timer.ts | 1 + src/jsx/props/Toggle.ts | 1 + src/jsx/props/VStack.ts | 1 + src/jsx/props/ZStack.ts | 1 + src/payload/component-ids.ts | 114 +- src/payload/short-names.ts | 518 +- src/renderer/flatten-styles.ts | 7 +- src/renderer/index.ts | 8 +- src/renderer/renderer.ts | 58 +- website/docs/_meta.json | 15 +- website/docs/_nav.json | 12 +- website/docs/android/_meta.json | 27 + website/docs/android/components/_meta.json | 22 + .../docs/android/components/interactive.md | 97 + website/docs/android/components/layout.md | 91 + website/docs/android/components/status.md | 27 + website/docs/android/components/visual.md | 32 + website/docs/android/development/_meta.json | 22 + .../android/development/developing-widgets.md | 65 + .../android/development/image-preloading.md | 96 + website/docs/android/development/styling.md | 80 + .../development/testing-and-previews.md | 72 + .../development/widget-pre-rendering.md | 58 + website/docs/android/introduction.md | 61 + website/docs/android/setup.mdx | 53 + website/docs/components/_meta.json | 27 - website/docs/components/index.md | 49 - website/docs/components/interactive.md | 35 - website/docs/components/layout.md | 137 - website/docs/components/status.md | 80 - website/docs/components/visual.md | 128 - website/docs/getting-started/_meta.json | 8 +- website/docs/getting-started/installation.md | 18 + website/docs/getting-started/introduction.md | 18 + website/docs/index.md | 22 +- website/docs/ios/_meta.json | 27 + website/docs/{ => ios}/api/_meta.json | 0 website/docs/{ => ios}/api/configuration.md | 2 +- .../{ => ios}/api/plugin-configuration.md | 2 +- website/docs/ios/components/_meta.json | 22 + website/docs/ios/components/interactive.md | 13 + website/docs/ios/components/layout.md | 62 + website/docs/ios/components/status.md | 25 + website/docs/ios/components/visual.md | 57 + website/docs/{ => ios}/development/_meta.json | 0 .../development/developing-live-activities.md | 0 .../development/developing-widgets.md | 0 website/docs/{ => ios}/development/events.md | 0 .../{ => ios}/development/image-preloading.md | 0 website/docs/{ => ios}/development/images.md | 0 .../{ => ios}/development/interactions.md | 0 .../managing-live-activities-locally.md | 0 .../docs/{ => ios}/development/performance.md | 0 .../development/server-side-updates.md | 0 website/docs/{ => ios}/development/styling.md | 0 .../development/widget-pre-rendering.md | 0 .../index.md => ios/introduction.md} | 2 +- .../quick-start.mdx => ios/setup.mdx} | 20 +- website/package-lock.json | 1420 +-- website/package.json | 6 +- 303 files changed, 23674 insertions(+), 2455 deletions(-) create mode 100644 .editorconfig create mode 100644 android/src/main/java/voltra/ComponentRegistry.kt delete mode 100644 android/src/main/java/voltra/VoltraModuleView.kt create mode 100644 android/src/main/java/voltra/VoltraNotificationManager.kt create mode 100644 android/src/main/java/voltra/VoltraRN.kt create mode 100644 android/src/main/java/voltra/events/VoltraEvent.kt create mode 100644 android/src/main/java/voltra/events/VoltraEventBus.kt create mode 100644 android/src/main/java/voltra/generated/ShortNames.kt create mode 100644 android/src/main/java/voltra/glance/GlanceFactory.kt create mode 100644 android/src/main/java/voltra/glance/RemoteViewsGenerator.kt create mode 100644 android/src/main/java/voltra/glance/StyleUtils.kt create mode 100644 android/src/main/java/voltra/glance/VoltraRenderContext.kt create mode 100644 android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/InputRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/LayoutRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/ProgressRenderers.kt create mode 100644 android/src/main/java/voltra/glance/renderers/RenderCommon.kt create mode 100644 android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt create mode 100644 android/src/main/java/voltra/images/VoltraImageManager.kt create mode 100644 android/src/main/java/voltra/models/VoltraPayload.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidBoxParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidCheckBoxParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidCircleIconButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidCircularProgressIndicatorParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidColumnParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidFilledButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidLazyColumnParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidLazyVerticalGridParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidLinearProgressIndicatorParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidOutlineButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidRadioButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidRowParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidScaffoldParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidSpacerParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidSquareIconButtonParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidSwitchParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidTextParameters.kt create mode 100644 android/src/main/java/voltra/models/parameters/AndroidTitleBarParameters.kt create mode 100644 android/src/main/java/voltra/parsing/VoltraDecompressor.kt create mode 100644 android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt create mode 100644 android/src/main/java/voltra/parsing/VoltraPayloadParser.kt create mode 100644 android/src/main/java/voltra/payload/ComponentTypeID.kt create mode 100644 android/src/main/java/voltra/styling/JSColorParser.kt create mode 100644 android/src/main/java/voltra/styling/JSStyleParser.kt create mode 100644 android/src/main/java/voltra/styling/StyleConverter.kt create mode 100644 android/src/main/java/voltra/styling/StyleModifiers.kt create mode 100644 android/src/main/java/voltra/styling/StyleStructures.kt create mode 100644 android/src/main/java/voltra/widget/VoltraGlanceWidget.kt create mode 100644 android/src/main/java/voltra/widget/VoltraWidgetManager.kt create mode 100644 android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt create mode 100644 android/src/main/res/xml/voltra_file_paths.xml create mode 100644 example/app/android-widgets.tsx create mode 100644 example/app/android-widgets/image-preloading.tsx create mode 100644 example/app/android-widgets/interactive.tsx create mode 100644 example/app/android-widgets/pin.tsx create mode 100644 example/app/android-widgets/preview.tsx create mode 100644 example/assets/voltra-android/voltra-logo.svg create mode 100644 example/screens/android/AndroidImagePreloadingScreen.tsx create mode 100644 example/screens/android/AndroidInteractiveWidgetScreen.tsx create mode 100644 example/screens/android/AndroidPreviewScreen.tsx create mode 100644 example/screens/android/AndroidScreen.tsx create mode 100644 example/screens/android/AndroidWidgetPinScreen.tsx create mode 100644 example/screens/live-activities/BasicAndroidLiveUpdate.tsx create mode 100644 example/widgets/AndroidVoltraWidget.tsx create mode 100644 example/widgets/android-voltra-widget-initial.tsx create mode 100644 example/widgets/updateAndroidVoltraWidget.tsx create mode 100644 generator/generators/kotlin-parameters.ts create mode 100644 ios/ui/Generated/Parameters/AndroidCheckBoxParameters.swift create mode 100644 ios/ui/Generated/Parameters/AndroidRadioButtonParameters.swift create mode 100644 ios/ui/Generated/Parameters/AndroidSwitchParameters.swift create mode 100644 ios/ui/Generated/Parameters/FilledButtonParameters.swift create mode 100644 plugin/src/features/android/files/assets/images.ts create mode 100644 plugin/src/features/android/files/assets/index.ts create mode 100644 plugin/src/features/android/files/index.ts create mode 100644 plugin/src/features/android/files/initialStates.ts create mode 100644 plugin/src/features/android/files/kotlin/index.ts create mode 100644 plugin/src/features/android/files/kotlin/widgetReceiver.ts create mode 100644 plugin/src/features/android/files/xml/index.ts create mode 100644 plugin/src/features/android/files/xml/placeholderLayout.ts create mode 100644 plugin/src/features/android/files/xml/stringResources.ts create mode 100644 plugin/src/features/android/files/xml/widgetInfo.ts create mode 100644 plugin/src/features/android/index.ts create mode 100644 plugin/src/features/android/manifest/index.ts rename plugin/src/{features/ios/files/swift => utils}/prerender.ts (87%) create mode 100644 plugin/src/validation/validateAndroidWidget.ts create mode 100644 pnpm-lock.yaml create mode 100644 src/android/client.ts create mode 100644 src/android/components/VoltraView.tsx create mode 100644 src/android/components/VoltraWidgetPreview.tsx create mode 100644 src/android/index.ts create mode 100644 src/android/jsx/Box.tsx create mode 100644 src/android/jsx/Button.tsx create mode 100644 src/android/jsx/CheckBox.tsx create mode 100644 src/android/jsx/CircleIconButton.tsx create mode 100644 src/android/jsx/CircularProgressIndicator.tsx create mode 100644 src/android/jsx/Column.tsx create mode 100644 src/android/jsx/FilledButton.tsx create mode 100644 src/android/jsx/Image.tsx create mode 100644 src/android/jsx/LazyColumn.tsx create mode 100644 src/android/jsx/LazyVerticalGrid.tsx create mode 100644 src/android/jsx/LinearProgressIndicator.tsx create mode 100644 src/android/jsx/OutlineButton.tsx create mode 100644 src/android/jsx/RadioButton.tsx create mode 100644 src/android/jsx/Row.tsx create mode 100644 src/android/jsx/Scaffold.tsx create mode 100644 src/android/jsx/Spacer.tsx create mode 100644 src/android/jsx/SquareIconButton.tsx create mode 100644 src/android/jsx/Switch.tsx create mode 100644 src/android/jsx/Text.tsx create mode 100644 src/android/jsx/TitleBar.tsx create mode 100644 src/android/jsx/baseProps.tsx create mode 100644 src/android/jsx/index.ts create mode 100644 src/android/jsx/primitives.ts create mode 100644 src/android/jsx/props/Box.ts create mode 100644 src/android/jsx/props/Button.ts create mode 100644 src/android/jsx/props/CheckBox.ts create mode 100644 src/android/jsx/props/CircleIconButton.ts create mode 100644 src/android/jsx/props/CircularProgressIndicator.ts create mode 100644 src/android/jsx/props/Column.ts create mode 100644 src/android/jsx/props/FilledButton.ts create mode 100644 src/android/jsx/props/Image.ts create mode 100644 src/android/jsx/props/LazyColumn.ts create mode 100644 src/android/jsx/props/LazyVerticalGrid.ts create mode 100644 src/android/jsx/props/LinearProgressIndicator.ts create mode 100644 src/android/jsx/props/OutlineButton.ts create mode 100644 src/android/jsx/props/RadioButton.ts create mode 100644 src/android/jsx/props/Row.ts create mode 100644 src/android/jsx/props/Scaffold.ts create mode 100644 src/android/jsx/props/Spacer.ts create mode 100644 src/android/jsx/props/SquareIconButton.ts create mode 100644 src/android/jsx/props/Switch.ts create mode 100644 src/android/jsx/props/Text.ts create mode 100644 src/android/jsx/props/TitleBar.ts create mode 100644 src/android/live-update/api.ts create mode 100644 src/android/live-update/renderer.ts create mode 100644 src/android/live-update/types.ts create mode 100644 src/android/payload/component-ids.ts create mode 100644 src/android/preload.ts create mode 100644 src/android/server.ts create mode 100644 src/android/styles/types.ts create mode 100644 src/android/widgets/api.ts create mode 100644 src/android/widgets/index.ts create mode 100644 src/android/widgets/renderer.ts create mode 100644 src/android/widgets/types.ts create mode 100644 src/jsx/props/AndroidBox.ts create mode 100644 src/jsx/props/AndroidButton.ts create mode 100644 src/jsx/props/AndroidCheckBox.ts create mode 100644 src/jsx/props/AndroidCircleIconButton.ts create mode 100644 src/jsx/props/AndroidCircularProgressIndicator.ts create mode 100644 src/jsx/props/AndroidColumn.ts create mode 100644 src/jsx/props/AndroidFilledButton.ts create mode 100644 src/jsx/props/AndroidImage.ts create mode 100644 src/jsx/props/AndroidLazyColumn.ts create mode 100644 src/jsx/props/AndroidLazyVerticalGrid.ts create mode 100644 src/jsx/props/AndroidLinearProgressIndicator.ts create mode 100644 src/jsx/props/AndroidOutlineButton.ts create mode 100644 src/jsx/props/AndroidRadioButton.ts create mode 100644 src/jsx/props/AndroidRow.ts create mode 100644 src/jsx/props/AndroidScaffold.ts create mode 100644 src/jsx/props/AndroidSpacer.ts create mode 100644 src/jsx/props/AndroidSquareIconButton.ts create mode 100644 src/jsx/props/AndroidSwitch.ts create mode 100644 src/jsx/props/AndroidText.ts create mode 100644 src/jsx/props/AndroidTitleBar.ts create mode 100644 src/jsx/props/FilledButton.ts create mode 100644 src/jsx/props/LegacyAndroidCheckBox.ts create mode 100644 src/jsx/props/LegacyAndroidRadioButton.ts create mode 100644 src/jsx/props/LegacyAndroidSwitch.ts create mode 100644 src/jsx/props/LegacyFilledButton.ts create mode 100644 src/jsx/props/LegacyImage.ts create mode 100644 website/docs/android/_meta.json create mode 100644 website/docs/android/components/_meta.json create mode 100644 website/docs/android/components/interactive.md create mode 100644 website/docs/android/components/layout.md create mode 100644 website/docs/android/components/status.md create mode 100644 website/docs/android/components/visual.md create mode 100644 website/docs/android/development/_meta.json create mode 100644 website/docs/android/development/developing-widgets.md create mode 100644 website/docs/android/development/image-preloading.md create mode 100644 website/docs/android/development/styling.md create mode 100644 website/docs/android/development/testing-and-previews.md create mode 100644 website/docs/android/development/widget-pre-rendering.md create mode 100644 website/docs/android/introduction.md create mode 100644 website/docs/android/setup.mdx delete mode 100644 website/docs/components/_meta.json delete mode 100644 website/docs/components/index.md delete mode 100644 website/docs/components/interactive.md delete mode 100644 website/docs/components/layout.md delete mode 100644 website/docs/components/status.md delete mode 100644 website/docs/components/visual.md create mode 100644 website/docs/getting-started/installation.md create mode 100644 website/docs/getting-started/introduction.md create mode 100644 website/docs/ios/_meta.json rename website/docs/{ => ios}/api/_meta.json (100%) rename website/docs/{ => ios}/api/configuration.md (97%) rename website/docs/{ => ios}/api/plugin-configuration.md (97%) create mode 100644 website/docs/ios/components/_meta.json create mode 100644 website/docs/ios/components/interactive.md create mode 100644 website/docs/ios/components/layout.md create mode 100644 website/docs/ios/components/status.md create mode 100644 website/docs/ios/components/visual.md rename website/docs/{ => ios}/development/_meta.json (100%) rename website/docs/{ => ios}/development/developing-live-activities.md (100%) rename website/docs/{ => ios}/development/developing-widgets.md (100%) rename website/docs/{ => ios}/development/events.md (100%) rename website/docs/{ => ios}/development/image-preloading.md (100%) rename website/docs/{ => ios}/development/images.md (100%) rename website/docs/{ => ios}/development/interactions.md (100%) rename website/docs/{ => ios}/development/managing-live-activities-locally.md (100%) rename website/docs/{ => ios}/development/performance.md (100%) rename website/docs/{ => ios}/development/server-side-updates.md (100%) rename website/docs/{ => ios}/development/styling.md (100%) rename website/docs/{ => ios}/development/widget-pre-rendering.md (100%) rename website/docs/{getting-started/index.md => ios/introduction.md} (96%) rename website/docs/{getting-started/quick-start.mdx => ios/setup.mdx} (78%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2e252e3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{kt,kts}] +indent_size = 4 +max_line_length = 120 +ktlint_standard_function-naming = disabled +ktlint_standard_no-wildcard-imports = disabled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f182f..296881a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,3 +88,19 @@ jobs: - name: Check Swift formatting run: npm run format:swift:check + + ktlint: + name: ktlint Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Check Kotlin formatting + run: npm run format:kotlin:check diff --git a/.gitignore b/.gitignore index 230a8ba..4aa4664 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,20 @@ xcuserdata/ ## Playground app /example/ios +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml +android/app/libs +android/keystores/debug.keystore +build/ + ## Node.js node_modules/ npm-debug.log diff --git a/.npmignore b/.npmignore index e779527..6a9a35e 100644 --- a/.npmignore +++ b/.npmignore @@ -17,6 +17,7 @@ __tests__ /.prettierignore /.prettierrc /.swiftformat +/.editorconfig # Android build artifacts /android/src/androidTest/ diff --git a/android/build.gradle b/android/build.gradle index ea8aa83..87f1ee7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,4 +1,24 @@ +buildscript { + // 1. ADD THIS BLOCK TO RESOLVE THE PLUGIN + repositories { + google() + mavenCentral() + } + dependencies { + // Ensure this version matches your project's Kotlin version (e.g., 2.0.0, 2.0.20) + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.0" + classpath "org.jetbrains.kotlin:kotlin-serialization:2.0.21" + } + + // Existing helper + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } +} + apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' +apply plugin: 'org.jetbrains.kotlin.plugin.serialization' group = 'voltra' version = '0.1.0' @@ -25,7 +45,7 @@ if (useManagedAndroidSdkVersions) { project.android { compileSdkVersion safeExtGet("compileSdkVersion", 36) defaultConfig { - minSdkVersion safeExtGet("minSdkVersion", 24) + minSdkVersion safeExtGet("minSdkVersion", 31) targetSdkVersion safeExtGet("targetSdkVersion", 36) } } @@ -40,4 +60,25 @@ android { lintOptions { abortOnError false } + buildFeatures { + compose true + } +} + +dependencies { + // Jetpack Glance - use 'api' instead of 'implementation' to make these available to consuming apps + api "androidx.glance:glance:1.2.0-rc01" + api "androidx.glance:glance-appwidget:1.2.0-rc01" + + // Compose runtime (required for Glance) + api "androidx.compose.runtime:runtime:1.6.8" + + // JSON parsing + implementation "com.google.code.gson:gson:2.10.1" + + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + + // Kotlinx Serialization + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index bdae66c..97f6aec 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,15 @@ - - + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/java/voltra/ComponentRegistry.kt b/android/src/main/java/voltra/ComponentRegistry.kt new file mode 100644 index 0000000..621531a --- /dev/null +++ b/android/src/main/java/voltra/ComponentRegistry.kt @@ -0,0 +1,53 @@ +package voltra + +/** + * Maps component type IDs to names. + * Must match ANDROID_COMPONENT_NAME_TO_ID in TypeScript. + */ +object ComponentRegistry { + const val TEXT = 36 + const val COLUMN = 27 + const val ROW = 32 + const val BOX = 23 + const val SPACER = 34 + const val IMAGE = 4 + const val BUTTON = 24 + const val LINEAR_PROGRESS = 30 + const val CIRCULAR_PROGRESS = 26 + const val SWITCH = 7 + const val RADIO_BUTTON = 9 + const val CHECK_BOX = 8 + const val FILLED_BUTTON = 2 + const val OUTLINE_BUTTON = 31 + const val CIRCLE_ICON_BUTTON = 25 + const val SQUARE_ICON_BUTTON = 35 + const val TITLE_BAR = 37 + const val SCAFFOLD = 33 + const val LAZY_COLUMN = 28 + const val LAZY_VERTICAL_GRID = 29 + + fun getName(id: Int): String = + when (id) { + TEXT -> "AndroidText" + COLUMN -> "AndroidColumn" + ROW -> "AndroidRow" + BOX -> "AndroidBox" + SPACER -> "AndroidSpacer" + IMAGE -> "AndroidImage" + BUTTON -> "AndroidButton" + LINEAR_PROGRESS -> "AndroidLinearProgressIndicator" + CIRCULAR_PROGRESS -> "AndroidCircularProgressIndicator" + SWITCH -> "AndroidSwitch" + RADIO_BUTTON -> "AndroidRadioButton" + CHECK_BOX -> "AndroidCheckBox" + FILLED_BUTTON -> "AndroidFilledButton" + OUTLINE_BUTTON -> "AndroidOutlineButton" + CIRCLE_ICON_BUTTON -> "AndroidCircleIconButton" + SQUARE_ICON_BUTTON -> "AndroidSquareIconButton" + TITLE_BAR -> "AndroidTitleBar" + SCAFFOLD -> "AndroidScaffold" + LAZY_COLUMN -> "AndroidLazyColumn" + LAZY_VERTICAL_GRID -> "AndroidLazyVerticalGrid" + else -> "Unknown ($id)" + } +} diff --git a/android/src/main/java/voltra/VoltraModule.kt b/android/src/main/java/voltra/VoltraModule.kt index 3ef12a8..17ac355 100644 --- a/android/src/main/java/voltra/VoltraModule.kt +++ b/android/src/main/java/voltra/VoltraModule.kt @@ -1,50 +1,296 @@ package voltra +import android.util.Log +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition -import java.net.URL +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import voltra.events.VoltraEventBus +import voltra.images.VoltraImageManager +import voltra.widget.VoltraGlanceWidget +import voltra.widget.VoltraWidgetManager class VoltraModule : Module() { - // Each module class must implement the definition function. The definition consists of components - // that describes the module's functionality and behavior. - // See https://docs.expo.dev/modules/module-api for more details about available components. - override fun definition() = ModuleDefinition { - // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. - // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. - // The module will be accessible from `requireNativeModule('VoltraModule')` in JavaScript. - Name("VoltraModule") - - // Defines constant property on the module. - Constant("PI") { - Math.PI + companion object { + private const val TAG = "VoltraModule" } - // Defines event names that the module can send to JavaScript. - Events("onChange") + private val notificationManager by lazy { + VoltraNotificationManager(appContext.reactContext!!) + } - // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. - Function("hello") { - "Hello world! 👋" + private val widgetManager by lazy { + VoltraWidgetManager(appContext.reactContext!!) } - // Defines a JavaScript function that always returns a Promise and whose native code - // is by default dispatched on the different thread than the JavaScript runtime runs on. - AsyncFunction("setValueAsync") { value: String -> - // Send an event to JavaScript. - sendEvent("onChange", mapOf( - "value" to value - )) + private val imageManager by lazy { + VoltraImageManager(appContext.reactContext!!) } - // Enables the module to be used as a native view. Definition components that are accepted as part of - // the view definition: Prop, Events. - View(VoltraModuleView::class) { - // Defines a setter for the `url` prop. - Prop("url") { view: VoltraModuleView, url: URL -> - view.webView.loadUrl(url.toString()) - } - // Defines an event that the view can send to JavaScript. - Events("onLoad") + private val eventBus by lazy { + VoltraEventBus.getInstance(appContext.reactContext!!) } - } + + private var eventBusUnsubscribe: (() -> Unit)? = null + + override fun definition() = + ModuleDefinition { + Name("VoltraModule") + + OnStartObserving { + Log.d(TAG, "OnStartObserving: Starting event bus subscription") + + // Replay any persisted events from SharedPreferences (cold start) + val persistedEvents = eventBus.popAll() + Log.d(TAG, "Replaying ${persistedEvents.size} persisted events") + + persistedEvents.forEach { event -> + sendEvent(event.type, event.toMap()) + } + + // Subscribe to hot event delivery (broadcast receiver) + eventBusUnsubscribe = + eventBus.addListener { event -> + Log.d(TAG, "Received hot event: ${event.type}") + sendEvent(event.type, event.toMap()) + } + } + + OnStopObserving { + Log.d(TAG, "OnStopObserving: Unsubscribing from event bus") + eventBusUnsubscribe?.invoke() + eventBusUnsubscribe = null + } + + // Android Live Update APIs + + AsyncFunction("startAndroidLiveUpdate") { + payload: String, + options: Map, + -> + + Log.d(TAG, "startAndroidLiveUpdate called") + + val updateName = options["updateName"] as? String + val channelId = options["channelId"] as? String ?: "voltra_live_updates" + + Log.d(TAG, "updateName=$updateName, channelId=$channelId") + + val result = + runBlocking { + notificationManager.startLiveUpdate(payload, updateName, channelId) + } + + Log.d(TAG, "startAndroidLiveUpdate returning: $result") + result + } + + AsyncFunction("updateAndroidLiveUpdate") { + notificationId: String, + payload: String, + -> + + Log.d(TAG, "updateAndroidLiveUpdate called with notificationId=$notificationId") + + runBlocking { + notificationManager.updateLiveUpdate(notificationId, payload) + } + + Log.d(TAG, "updateAndroidLiveUpdate completed") + } + + AsyncFunction("stopAndroidLiveUpdate") { notificationId: String -> + Log.d(TAG, "stopAndroidLiveUpdate called with notificationId=$notificationId") + notificationManager.stopLiveUpdate(notificationId) + } + + Function("isAndroidLiveUpdateActive") { updateName: String -> + notificationManager.isLiveUpdateActive(updateName) + } + + AsyncFunction("endAllAndroidLiveUpdates") { + notificationManager.endAllLiveUpdates() + } + + // Android Widget APIs + + AsyncFunction("updateAndroidWidget") { + widgetId: String, + jsonString: String, + options: Map, + -> + + Log.d(TAG, "updateAndroidWidget called with widgetId=$widgetId") + + val deepLinkUrl = options["deepLinkUrl"] as? String + + widgetManager.writeWidgetData(widgetId, jsonString, deepLinkUrl) + + runBlocking { + widgetManager.updateWidget(widgetId) + } + + Log.d(TAG, "updateAndroidWidget completed") + } + + AsyncFunction("reloadAndroidWidgets") { widgetIds: ArrayList? -> + Log.d(TAG, "reloadAndroidWidgets called with widgetIds=$widgetIds") + + runBlocking { + widgetManager.reloadWidgets(widgetIds) + } + + Log.d(TAG, "reloadAndroidWidgets completed") + } + + AsyncFunction("clearAndroidWidget") { widgetId: String -> + Log.d(TAG, "clearAndroidWidget called with widgetId=$widgetId") + + widgetManager.clearWidgetData(widgetId) + + runBlocking { + widgetManager.updateWidget(widgetId) + } + + Log.d(TAG, "clearAndroidWidget completed") + } + + AsyncFunction("clearAllAndroidWidgets") { + Log.d(TAG, "clearAllAndroidWidgets called") + + widgetManager.clearAllWidgetData() + + runBlocking { + widgetManager.reloadAllWidgets() + } + + Log.d(TAG, "clearAllAndroidWidgets completed") + } + + AsyncFunction("requestPinGlanceAppWidget") { + widgetId: String, + options: Map?, + -> + + Log.d(TAG, "requestPinGlanceAppWidget called with widgetId=$widgetId") + + val context = appContext.reactContext!! + + // Construct the receiver class name following the convention + val receiverClassName = "${context.packageName}.widget.VoltraWidget_${widgetId}Receiver" + + Log.d(TAG, "Looking for receiver: $receiverClassName") + + // Get the receiver class using reflection + val receiverClass = + try { + Class.forName(receiverClassName) as Class + } catch (e: ClassNotFoundException) { + Log.e(TAG, "Widget receiver class not found: $receiverClassName", e) + throw IllegalArgumentException("Widget receiver not found for id: $widgetId") + } + + // Get GlanceAppWidgetManager and request pin + val glanceManager = GlanceAppWidgetManager(context) + + // Parse preview size from options (optional) + // See: https://developer.android.com/develop/ui/compose/glance/pin-in-app + val previewSize = + if (options != null) { + val width = (options["previewWidth"] as? Number)?.toFloat() + val height = (options["previewHeight"] as? Number)?.toFloat() + if (width != null && height != null) { + DpSize(width.dp, height.dp) + } else { + null + } + } else { + null + } + + val result = + runBlocking { + // requestPinGlanceAppWidget is a suspend function + // See: https://developer.android.com/develop/ui/compose/glance/pin-in-app + if (previewSize != null) { + // Create preview widget with preview dimensions + val previewWidget = VoltraGlanceWidget(widgetId) + glanceManager.requestPinGlanceAppWidget( + receiver = receiverClass, + preview = previewWidget, + previewState = previewSize, + ) + } else { + // Basic pin request without preview + glanceManager.requestPinGlanceAppWidget(receiverClass) + } + } + + Log.d(TAG, "requestPinGlanceAppWidget completed with result=$result") + result + } + + AsyncFunction("preloadImages") { images: List> -> + Log.d(TAG, "preloadImages called with ${images.size} images") + + runBlocking { + val results = + images + .map { img -> + async { + val url = img["url"] as String + val key = img["key"] as String + val method = (img["method"] as? String) ?: "GET" + + @Suppress("UNCHECKED_CAST") + val headers = img["headers"] as? Map + + val resultKey = imageManager.preloadImage(url, key, method, headers) + if (resultKey != null) { + Pair(key, null) + } else { + Pair(key, "Failed to download image") + } + } + }.awaitAll() + + val succeeded = results.filter { it.second == null }.map { it.first } + val failed = + results.filter { it.second != null }.map { + mapOf("key" to it.first, "error" to it.second) + } + + mapOf( + "succeeded" to succeeded, + "failed" to failed, + ) + } + } + + AsyncFunction("clearPreloadedImages") { keys: List? -> + Log.d(TAG, "clearPreloadedImages called with keys=$keys") + imageManager.clearPreloadedImages(keys) + } + + AsyncFunction("reloadLiveActivities") { activityNames: List? -> + // On Android, we don't have "Live Activities" in the same sense as iOS, + // but we might want to refresh widgets or notifications. + // For now, this is a no-op to match iOS API if called. + Log.d(TAG, "reloadLiveActivities called (no-op on Android)") + } + + View(VoltraRN::class) { + Prop("payload") { view, payload: String -> + view.setPayload(payload) + } + + Prop("viewId") { view, viewId: String -> + view.setViewId(viewId) + } + } + } } diff --git a/android/src/main/java/voltra/VoltraModuleView.kt b/android/src/main/java/voltra/VoltraModuleView.kt deleted file mode 100644 index a4a430a..0000000 --- a/android/src/main/java/voltra/VoltraModuleView.kt +++ /dev/null @@ -1,30 +0,0 @@ -package voltra - -import android.content.Context -import android.webkit.WebView -import android.webkit.WebViewClient -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ExpoView - -class VoltraModuleView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { - // Creates and initializes an event dispatcher for the `onLoad` event. - // The name of the event is inferred from the value and needs to match the event name defined in the module. - private val onLoad by EventDispatcher() - - // Defines a WebView that will be used as the root subview. - internal val webView = WebView(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String) { - // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. - onLoad(mapOf("url" to url)) - } - } - } - - init { - // Adds the WebView to the view hierarchy. - addView(webView) - } -} diff --git a/android/src/main/java/voltra/VoltraNotificationManager.kt b/android/src/main/java/voltra/VoltraNotificationManager.kt new file mode 100644 index 0000000..85c961d --- /dev/null +++ b/android/src/main/java/voltra/VoltraNotificationManager.kt @@ -0,0 +1,157 @@ +package voltra + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import voltra.glance.RemoteViewsGenerator +import voltra.parsing.VoltraPayloadParser +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +class VoltraNotificationManager( + private val context: Context, +) { + companion object { + private const val TAG = "VoltraNotificationMgr" + } + + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val activeNotifications = ConcurrentHashMap() + private val idCounter = AtomicInteger(10000) + + suspend fun startLiveUpdate( + payload: String, + updateName: String?, + channelId: String, + ): String = + withContext(Dispatchers.Default) { + Log.d(TAG, "startLiveUpdate called with updateName=$updateName, channelId=$channelId") + Log.d(TAG, "Payload (first 200 chars): ${payload.take(200)}") + + val voltraPayload = VoltraPayloadParser.parse(payload) + val notificationId = updateName ?: "live-update-${idCounter.getAndIncrement()}" + val intId = notificationId.hashCode().and(0x7FFFFFFF) // Ensure positive + + Log.d(TAG, "Parsed payload, notificationId=$notificationId, intId=$intId") + + createNotificationChannel(channelId) + + val collapsedView = RemoteViewsGenerator.generateCollapsed(context, voltraPayload) + val expandedView = RemoteViewsGenerator.generateExpanded(context, voltraPayload) + + Log.d(TAG, "Generated views: collapsed=${collapsedView != null}, expanded=${expandedView != null}") + + val notification = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(getSmallIcon(voltraPayload.smallIcon)) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .apply { + collapsedView?.let { setCustomContentView(it) } + expandedView?.let { setCustomBigContentView(it) } + }.build() + + notificationManager.notify(intId, notification) + activeNotifications[notificationId] = intId + + Log.d(TAG, "Notification posted. Active notifications: ${activeNotifications.keys}") + + notificationId + } + + suspend fun updateLiveUpdate( + notificationId: String, + payload: String, + ) = withContext(Dispatchers.Default) { + Log.d(TAG, "updateLiveUpdate called with notificationId=$notificationId") + Log.d(TAG, "Active notifications: ${activeNotifications.keys}") + + val intId = activeNotifications[notificationId] + if (intId == null) { + Log.e(TAG, "Notification $notificationId not found in activeNotifications!") + return@withContext + } + + Log.d(TAG, "Found intId=$intId for notificationId=$notificationId") + + val voltraPayload = VoltraPayloadParser.parse(payload) + val channelId = voltraPayload.channelId ?: "voltra_live_updates" + + val collapsedView = RemoteViewsGenerator.generateCollapsed(context, voltraPayload) + val expandedView = RemoteViewsGenerator.generateExpanded(context, voltraPayload) + + Log.d(TAG, "Update generated views: collapsed=${collapsedView != null}, expanded=${expandedView != null}") + + val notification = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(getSmallIcon(voltraPayload.smallIcon)) + .setOngoing(true) + .setOnlyAlertOnce(true) // Don't make sound/vibration on updates + .setWhen(System.currentTimeMillis()) // Force timestamp update + .setShowWhen(false) // But don't show the time + .apply { + collapsedView?.let { setCustomContentView(it) } + expandedView?.let { setCustomBigContentView(it) } + }.build() + + // Force notification flags to allow updates + notification.flags = notification.flags or android.app.Notification.FLAG_ONGOING_EVENT + + notificationManager.notify(intId, notification) + Log.d(TAG, "Notification updated successfully") + } + + fun stopLiveUpdate(notificationId: String) { + Log.d(TAG, "stopLiveUpdate called with notificationId=$notificationId") + activeNotifications.remove(notificationId)?.let { intId -> + notificationManager.cancel(intId) + Log.d(TAG, "Notification cancelled") + } + } + + fun isLiveUpdateActive(updateName: String): Boolean = activeNotifications.containsKey(updateName) + + fun endAllLiveUpdates() { + Log.d(TAG, "endAllLiveUpdates called") + activeNotifications.forEach { (_, intId) -> + notificationManager.cancel(intId) + } + activeNotifications.clear() + } + + private fun createNotificationChannel(channelId: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + channelId, + "Voltra Live Updates", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Live update notifications from Voltra" + } + notificationManager.createNotificationChannel(channel) + Log.d(TAG, "Notification channel created: $channelId") + } + } + + private fun getSmallIcon(iconName: String?): Int { + if (iconName != null) { + val resId = + context.resources.getIdentifier( + iconName, + "drawable", + context.packageName, + ) + if (resId != 0) return resId + } + return context.applicationInfo.icon + } +} diff --git a/android/src/main/java/voltra/VoltraRN.kt b/android/src/main/java/voltra/VoltraRN.kt new file mode 100644 index 0000000..35cc1c0 --- /dev/null +++ b/android/src/main/java/voltra/VoltraRN.kt @@ -0,0 +1,192 @@ +package voltra + +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.views.ExpoView +import kotlinx.coroutines.* +import voltra.glance.GlanceFactory +import voltra.parsing.VoltraPayloadParser +import kotlin.math.abs + +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +class VoltraRN( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext) { + private var mainScope: CoroutineScope? = null + private val frameLayout = FrameLayout(context) + private var viewId: String? = null + private var payload: String? = null + private var updateJob: Job? = null + + private var lastRenderedPayload: String? = null + private var lastRenderedWidthDp: Float = 0f + private var lastRenderedHeightDp: Float = 0f + + init { + // Ensure FrameLayout takes full space + frameLayout.layoutParams = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + addView(frameLayout) + } + + fun setViewId(id: String) { + if (this.viewId == id) return + this.viewId = id + updateView() + } + + fun setPayload(payload: String) { + if (this.payload == payload) return + this.payload = payload + updateView() + } + + private fun updateView() { + val payloadStr = payload ?: return + val id = viewId ?: return + + val density = context.resources.displayMetrics.density + val widthDp = frameLayout.width.toFloat() / density + val heightDp = frameLayout.height.toFloat() / density + + // Avoid redundant updates if nothing significant changed and we already have a view + if (frameLayout.childCount > 0 && + payloadStr == lastRenderedPayload && + abs(widthDp - lastRenderedWidthDp) < 1.0f && + abs(heightDp - lastRenderedHeightDp) < 1.0f + ) { + return + } + + updateJob?.cancel() + updateJob = + mainScope?.launch { + try { + // Parse payload on background thread + val voltraPayload = + withContext(Dispatchers.Default) { + try { + VoltraPayloadParser.parse(payloadStr) + } catch (e: Exception) { + null + } + } ?: return@launch + + val node = + voltraPayload.collapsed + ?: voltraPayload.expanded + ?: voltraPayload.variants?.get("content") + ?: voltraPayload.variants?.values?.firstOrNull() + + if (node == null) { + frameLayout.removeAllViews() + return@launch + } + + // Determine size for Glance composition. + // Glance needs a non-zero size. If we don't have one yet, use a fallback. + val composeSize = + if (widthDp > 1f && heightDp > 1f) { + DpSize(widthDp.dp, heightDp.dp) + } else { + DpSize(300.dp, 200.dp) + } + + val glanceRemoteViews = GlanceRemoteViews() + val factory = GlanceFactory(id, voltraPayload.e, voltraPayload.s) + + val result = + withContext(Dispatchers.Default) { + glanceRemoteViews.compose(context, composeSize) { + factory.Render(node) + } + } + + ensureActive() + + val remoteViews = result.remoteViews + + withContext(Dispatchers.Main) { + try { + // Try to reapply to the existing view first to avoid flickering/replacing + var applied = false + if (frameLayout.childCount > 0) { + try { + val existingView = frameLayout.getChildAt(0) + remoteViews.reapply(context, existingView) + applied = true + // Update tracking state + lastRenderedPayload = payloadStr + lastRenderedWidthDp = widthDp + lastRenderedHeightDp = heightDp + } catch (e: Exception) { + } + } + + if (!applied) { + // Inflate with parent to ensure correct LayoutParams, but don't attach yet + val inflatedView = remoteViews.apply(context, frameLayout) + + // Add new view FIRST, then remove old ones to prevent flickering + frameLayout.addView(inflatedView) + + val childCount = frameLayout.childCount + if (childCount > 1) { + frameLayout.removeViews(0, childCount - 1) + } + + // Update tracking state + lastRenderedPayload = payloadStr + lastRenderedWidthDp = widthDp + lastRenderedHeightDp = heightDp + } + } catch (e: Exception) { + } + } + } catch (e: CancellationException) { + } catch (e: Exception) { + } + } + } + + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int, + ) { + super.onLayout(changed, left, top, right, bottom) + if (changed) { + val w = right - left + val h = bottom - top + // Only trigger update if we actually have a size now + if (w > 0 && h > 0) { + updateView() + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + updateView() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + updateJob?.cancel() + mainScope?.cancel() + mainScope = null + } +} diff --git a/android/src/main/java/voltra/events/VoltraEvent.kt b/android/src/main/java/voltra/events/VoltraEvent.kt new file mode 100644 index 0000000..5f059d7 --- /dev/null +++ b/android/src/main/java/voltra/events/VoltraEvent.kt @@ -0,0 +1,25 @@ +package voltra.events + +/** + * Represents a Voltra event that can be sent from widgets to the React Native app. + * Events are persisted to SharedPreferences to survive app process death. + */ +sealed class VoltraEvent { + abstract val type: String + abstract val timestamp: Long + + /** + * Convert event to a map for React Native bridge. + */ + abstract fun toMap(): Map + + companion object { + /** + * Parse event from persisted map data. + */ + fun fromMap(map: Map): VoltraEvent? { + // No events supported currently + return null + } + } +} diff --git a/android/src/main/java/voltra/events/VoltraEventBus.kt b/android/src/main/java/voltra/events/VoltraEventBus.kt new file mode 100644 index 0000000..9f9e1c2 --- /dev/null +++ b/android/src/main/java/voltra/events/VoltraEventBus.kt @@ -0,0 +1,198 @@ +package voltra.events + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject + +/** + * A centralized event bus that manages Voltra events from widgets to the React Native app. + * + * Event delivery uses a dual-path approach: + * - Persistent: Events are written to SharedPreferences (survives app death) + * - Hot: Events are broadcast via LocalBroadcastManager (immediate delivery when app is running) + * + * This mirrors the iOS implementation using UserDefaults + NotificationCenter. + */ +class VoltraEventBus private constructor( + private val context: Context, +) { + companion object { + private const val TAG = "VoltraEventBus" + private const val PREFS_NAME = "voltra_event_queue" + private const val KEY_EVENTS = "events" + private const val ACTION_VOLTRA_EVENT = "voltra.event.interaction" + + @Volatile + private var instance: VoltraEventBus? = null + + fun getInstance(context: Context): VoltraEventBus = + instance ?: synchronized(this) { + instance ?: VoltraEventBus(context.applicationContext).also { instance = it } + } + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val lock = Any() + + /** + * Send an event. Uses dual-path delivery: + * 1. Persist to SharedPreferences (survives app death) + * 2. Broadcast via Intent (hot delivery when app is running) + */ + fun send(event: VoltraEvent) { + Log.d( + TAG, + "Sending event: ${event.type}", + ) + + // 1. Persist to SharedPreferences + persistEvent(event) + + // 2. Broadcast for hot delivery (best-effort) + try { + val intent = + Intent(ACTION_VOLTRA_EVENT).apply { + // Explicitly set package to ensure broadcast is delivered to our receiver + // This is required for RECEIVER_NOT_EXPORTED on Android 13+ + setPackage(context.packageName) + putExtra("eventType", event.type) + event.toMap().forEach { (key, value) -> + when (value) { + is String -> putExtra(key, value) + is Long -> putExtra(key, value) + is Int -> putExtra(key, value) + is Boolean -> putExtra(key, value) + is Double -> putExtra(key, value) + null -> putExtra(key, "") + } + } + } + context.sendBroadcast(intent) + Log.d(TAG, "Broadcast sent for event: ${event.type}") + } catch (e: Exception) { + Log.w(TAG, "Failed to broadcast event (app may not be running): ${e.message}") + } + } + + /** + * Get all persisted events and clear the queue. + * Called when the React Native app starts to replay missed events. + */ + fun popAll(): List { + synchronized(lock) { + val events = readPersistedEvents() + if (events.isNotEmpty()) { + prefs.edit().remove(KEY_EVENTS).apply() + Log.d(TAG, "Popped ${events.size} persisted events") + } + return events + } + } + + /** + * Get all persisted events without clearing the queue. + */ + fun peekAll(): List { + synchronized(lock) { + return readPersistedEvents() + } + } + + /** + * Clear all persisted events. + */ + fun clearAll() { + synchronized(lock) { + prefs.edit().remove(KEY_EVENTS).apply() + Log.d(TAG, "Cleared all persisted events") + } + } + + private fun persistEvent(event: VoltraEvent) { + synchronized(lock) { + try { + val events = readPersistedEvents().toMutableList() + events.add(event) + + // Convert to JSON array + val jsonArray = JSONArray() + events.forEach { evt -> + val jsonObject = JSONObject(evt.toMap()) + jsonArray.put(jsonObject) + } + + prefs.edit().putString(KEY_EVENTS, jsonArray.toString()).apply() + Log.d(TAG, "Persisted event: ${event.type}, total in queue: ${events.size}") + } catch (e: Exception) { + Log.e(TAG, "Failed to persist event: ${e.message}", e) + } + } + } + + private fun readPersistedEvents(): List { + return try { + val jsonString = prefs.getString(KEY_EVENTS, null) ?: return emptyList() + val jsonArray = JSONArray(jsonString) + + val events = mutableListOf() + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val map = + jsonObject.keys().asSequence().associateWith { key -> + jsonObject.get(key) + } + + VoltraEvent.fromMap(map)?.let { events.add(it) } + } + + events + } catch (e: Exception) { + Log.e(TAG, "Failed to read persisted events: ${e.message}", e) + emptyList() + } + } + + /** + * Register a listener for hot event delivery. + * Returns a function to unregister the listener. + */ + fun addListener(listener: (VoltraEvent) -> Unit): () -> Unit { + val receiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent == null) return + + try { + val eventType = intent.getStringExtra("eventType") ?: return + val eventMap = mutableMapOf() + + intent.extras?.keySet()?.forEach { key -> + eventMap[key] = intent.extras?.get(key) + } + + VoltraEvent.fromMap(eventMap)?.let { listener(it) } + } catch (e: Exception) { + Log.e(TAG, "Error processing broadcast: ${e.message}", e) + } + } + } + + val filter = IntentFilter(ACTION_VOLTRA_EVENT) + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + + return { + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister receiver: ${e.message}") + } + } + } +} diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt new file mode 100644 index 0000000..a5c25e5 --- /dev/null +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -0,0 +1,165 @@ +// +// ShortNames.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.generated + +/** + * Unified short name mappings for props and style properties + * Used to expand compressed payload keys back to their full names + */ +object ShortNames { + /** Mapping from short names to full names */ + private val shortToName: Map = mapOf( + "ap" to "absolutePosition", + "al" to "alignment", + "ar" to "aspectRatio", + "an" to "assetName", + "ahe" to "autoHideOnEnd", + "bkg" to "background", + "bg" to "backgroundColor", + "bgs" to "backgroundStyle", + "b64" to "base64", + "bd" to "border", + "bc" to "borderColor", + "br" to "borderRadius", + "bw" to "borderWidth", + "b" to "bottom", + "bs" to "buttonStyle", + "chk" to "checked", + "clip" to "clipped", + "c" to "color", + "cls" to "colors", + "ca" to "contentAlignment", + "cc" to "contentColor", + "cdesc" to "contentDescription", + "cr" to "cornerRadius", + "cd" to "countDown", + "cvl" to "currentValueLabel", + "dlu" to "deepLinkUrl", + "dv" to "defaultValue", + "dir" to "direction", + "dth" to "dither", + "dur" to "durationMs", + "en" to "enabled", + "end" to "endAtMs", + "ep" to "endPoint", + "fmh" to "fillMaxHeight", + "fmw" to "fillMaxWidth", + "fsh" to "fixedSizeHorizontal", + "fsv" to "fixedSizeVertical", + "fl" to "flex", + "fg" to "flexGrow", + "fgw" to "flexGrowWidth", + "fnt" to "font", + "fs" to "fontSize", + "fvar" to "fontVariant", + "fw" to "fontWeight", + "fgs" to "foregroundStyle", + "f" to "frame", + "gs" to "gaugeStyle", + "ge" to "glassEffect", + "h" to "height", + "halig" to "horizontalAlignment", + "hp" to "horizontalPadding", + "ic" to "icon", + "id" to "id", + "it" to "italic", + "kern" to "kerning", + "lbl" to "label", + "lp" to "layoutPriority", + "l" to "left", + "ls" to "letterSpacing", + "lh" to "lineHeight", + "ll" to "lineLimit", + "lsp" to "lineSpacing", + "lw" to "lineWidth", + "m" to "margin", + "mb" to "marginBottom", + "mh" to "marginHorizontal", + "ml" to "marginLeft", + "mr" to "marginRight", + "mt" to "marginTop", + "mv" to "marginVertical", + "me" to "maskElement", + "maxh" to "maxHeight", + "max" to "maximumValue", + "maxl" to "maximumValueLabel", + "mxl" to "maxLines", + "maxw" to "maxWidth", + "minh" to "minHeight", + "min" to "minimumValue", + "minvl" to "minimumValueLabel", + "minl" to "minLength", + "minw" to "minWidth", + "md" to "monospacedDigit", + "mta" to "multilineTextAlignment", + "n" to "name", + "nol" to "numberOfLines", + "off" to "offset", + "ox" to "offsetX", + "oy" to "offsetY", + "op" to "opacity", + "ov" to "overflow", + "pad" to "padding", + "pb" to "paddingBottom", + "ph" to "paddingHorizontal", + "pl" to "paddingLeft", + "pr" to "paddingRight", + "pt" to "paddingTop", + "pv" to "paddingVertical", + "pos" to "position", + "pc" to "progressColor", + "rm" to "resizeMode", + "r" to "right", + "re" to "rotationEffect", + "sc" to "scale", + "sce" to "scaleEffect", + "sh" to "shadow", + "shc" to "shadowColor", + "sho" to "shadowOffset", + "shop" to "shadowOpacity", + "shr" to "shadowRadius", + "sz" to "size", + "smc" to "smallCaps", + "src" to "source", + "sp" to "spacing", + "start" to "startAtMs", + "stp" to "startPoint", + "sts" to "stops", + "st" to "strikethrough", + "s" to "style", + "si" to "systemImage", + "txt" to "text", + "ta" to "textAlign", + "tdl" to "textDecorationLine", + "ts" to "textStyle", + "tt" to "textTemplates", + "th" to "thumb", + "tnt" to "tint", + "tc" to "tintColor", + "ttl" to "title", + "t" to "top", + "trc" to "trackColor", + "tf" to "transform", + "typ" to "type", + "ul" to "underline", + "v" to "value", + "valig" to "verticalAlignment", + "wt" to "weight", + "w" to "width", + "zi" to "zIndex" + ) + + /** + * Expand a short name to its full form + * @param short The short name (e.g., "bg", "al", "sp") + * @return The full name (e.g., "backgroundColor", "alignment", "spacing"), or the input if no mapping exists + */ + fun expand(short: String): String { + return shortToName[short] ?: short + } +} diff --git a/android/src/main/java/voltra/glance/GlanceFactory.kt b/android/src/main/java/voltra/glance/GlanceFactory.kt new file mode 100644 index 0000000..e19e1b2 --- /dev/null +++ b/android/src/main/java/voltra/glance/GlanceFactory.kt @@ -0,0 +1,20 @@ +package voltra.glance + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import voltra.glance.renderers.RenderNode +import voltra.models.VoltraNode + +class GlanceFactory( + private val widgetId: String, + private val sharedElements: List? = null, + private val sharedStyles: List>? = null, +) { + @Composable + fun Render(node: VoltraNode?) { + val context = VoltraRenderContext(widgetId, sharedElements, sharedStyles) + CompositionLocalProvider(LocalVoltraRenderContext provides context) { + RenderNode(node) + } + } +} diff --git a/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt b/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt new file mode 100644 index 0000000..c35cc49 --- /dev/null +++ b/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt @@ -0,0 +1,121 @@ +package voltra.glance + +import android.content.Context +import android.util.Log +import android.util.SizeF +import android.widget.RemoteViews +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import voltra.models.VoltraNode +import voltra.models.VoltraPayload + +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +object RemoteViewsGenerator { + private const val TAG = "RemoteViewsGenerator" + + /** + * Generate RemoteViews for collapsed notification content + */ + suspend fun generateCollapsed( + context: Context, + payload: VoltraPayload, + ): RemoteViews? { + val node = payload.collapsed ?: return null + Log.d(TAG, "Generating collapsed view") + return generate(context, node, payload.e, payload.s, DpSize(360.dp, 64.dp)) + } + + /** + * Generate RemoteViews for expanded notification content + */ + suspend fun generateExpanded( + context: Context, + payload: VoltraPayload, + ): RemoteViews? { + val node = payload.expanded ?: return null + Log.d(TAG, "Generating expanded view") + return generate(context, node, payload.e, payload.s, DpSize(360.dp, 256.dp)) + } + + private suspend fun generate( + context: Context, + node: VoltraNode, + sharedElements: List?, + sharedStyles: List>?, + size: DpSize, + ): RemoteViews { + // Create a new GlanceRemoteViews instance each time to avoid caching issues + val glanceRemoteViews = GlanceRemoteViews() + // Use empty widgetId for notification RemoteViews (not widget-specific) + val factory = GlanceFactory("notification", sharedElements, sharedStyles) + + Log.d(TAG, "Composing Glance content with size: $size, sharedStyles count: ${sharedStyles?.size ?: 0}") + + val result = + glanceRemoteViews.compose(context, size) { + factory.Render(node) + } + + Log.d(TAG, "RemoteViews generated successfully") + return result.remoteViews + } + + /** + * Generate RemoteViews for all widget size variants. + * Returns a mapping of SizeF to RemoteViews for Android 12+ responsive widgets. + * This bypasses Glance's session lock mechanism. + */ + suspend fun generateWidgetRemoteViews( + context: Context, + payload: VoltraPayload, + ): Map { + val variants = payload.variants ?: return emptyMap() + val sharedElements = payload.e + val sharedStyles = payload.s + + Log.d(TAG, "Generating widget RemoteViews for ${variants.size} variants") + + // Parse variant keys to get available sizes + val parsedVariants = + variants.keys.mapNotNull { key -> + val parts = key.split("x") + if (parts.size == 2) { + val width = parts[0].toFloatOrNull() + val height = parts[1].toFloatOrNull() + if (width != null && height != null) { + Triple(key, width, height) + } else { + null + } + } else { + null + } + } + + if (parsedVariants.isEmpty()) { + Log.w(TAG, "No valid size variants found in payload") + return emptyMap() + } + + val result = mutableMapOf() + + // Generate RemoteViews for each variant + for ((variantKey, width, height) in parsedVariants) { + val node = variants[variantKey] ?: continue + val size = DpSize(width.dp, height.dp) + + try { + val remoteViews = generate(context, node, sharedElements, sharedStyles, size) + result[SizeF(width, height)] = remoteViews + Log.d(TAG, "Generated RemoteViews for variant $variantKey (${width}x$height)") + } catch (e: Exception) { + Log.e(TAG, "Failed to generate RemoteViews for variant $variantKey: ${e.message}", e) + } + } + + Log.d(TAG, "Successfully generated ${result.size} widget RemoteViews") + return result + } +} diff --git a/android/src/main/java/voltra/glance/StyleUtils.kt b/android/src/main/java/voltra/glance/StyleUtils.kt new file mode 100644 index 0000000..0b19aee --- /dev/null +++ b/android/src/main/java/voltra/glance/StyleUtils.kt @@ -0,0 +1,122 @@ +package voltra.glance + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import voltra.ComponentRegistry +import voltra.glance.renderers.getOnClickAction +import voltra.styling.CompositeStyle +import voltra.styling.StyleConverter +import voltra.styling.applyStyle + +data class ResolvedStyle( + val modifier: GlanceModifier, + val compositeStyle: CompositeStyle?, +) + +fun resolveAndApplyStyle( + props: Map?, + sharedStyles: List>?, +): ResolvedStyle { + val resolvedStyle = resolveStyle(props, sharedStyles) + val compositeStyle = + if (resolvedStyle != null) { + StyleConverter.convert(resolvedStyle) + } else { + null + } + val modifier = + if (compositeStyle != null) { + GlanceModifier.applyStyle(compositeStyle) + } else { + GlanceModifier + } + return ResolvedStyle(modifier, compositeStyle) +} + +/** + * Resolve style reference to actual style map. + * Props may contain {"s": } where index references sharedStyles array, + * or {"s": {...}} for inline styles. + */ +private fun resolveStyle( + props: Map?, + sharedStyles: List>?, +): Map? { + if (props == null) return null + + val styleRef = props["style"] + return when (styleRef) { + is Number -> { + // It's an index into sharedStyles + val index = styleRef.toInt() + sharedStyles?.getOrNull(index) + } + is Map<*, *> -> { + // It's already an inline style + @Suppress("UNCHECKED_CAST") + styleRef as? Map + } + else -> null + } +} + +/** + * Apply clickable modifier if pressable prop is true. + * Skips components that already have built-in click handlers. + * + * @param modifier The GlanceModifier to enhance + * @param props The component props + * @param elementId The element's ID (from element.i) + * @param widgetId The widget ID for the action callback + * @param componentType The component type (from ComponentRegistry) + * @param elementHashCode The hash code of the element for generating fallback IDs + * @return The modifier with clickable applied if needed, otherwise unchanged + */ +@Composable +fun applyClickableIfNeeded( + modifier: GlanceModifier, + props: Map?, + elementId: String?, + widgetId: String, + componentType: Int, + elementHashCode: Int, +): GlanceModifier { + // Check if deepLinkUrl prop is set and not empty + val deepLinkUrl = (props?.get("dlu") as? String) ?: (props?.get("deepLinkUrl") as? String) + val isClickable = deepLinkUrl != null && deepLinkUrl.isNotEmpty() + + if (!isClickable) { + return modifier + } + + // Skip components that already have built-in click handlers + val exclusionList = + setOf( + ComponentRegistry.FILLED_BUTTON, + ComponentRegistry.OUTLINE_BUTTON, + ComponentRegistry.CIRCLE_ICON_BUTTON, + ComponentRegistry.SQUARE_ICON_BUTTON, + ComponentRegistry.SWITCH, + ComponentRegistry.RADIO_BUTTON, + ComponentRegistry.CHECK_BOX, + ) + + if (componentType in exclusionList) { + return modifier + } + + // Extract or generate component ID (prefer user-provided ID, fallback to generated) + val componentId = elementId ?: "pressable_$elementHashCode" + + // Apply clickable modifier + return modifier.clickable( + getOnClickAction( + LocalContext.current, + props, + widgetId, + componentId, + ), + ) +} diff --git a/android/src/main/java/voltra/glance/VoltraRenderContext.kt b/android/src/main/java/voltra/glance/VoltraRenderContext.kt new file mode 100644 index 0000000..8f4f5b2 --- /dev/null +++ b/android/src/main/java/voltra/glance/VoltraRenderContext.kt @@ -0,0 +1,15 @@ +package voltra.glance + +import androidx.compose.runtime.compositionLocalOf +import voltra.models.VoltraNode + +data class VoltraRenderContext( + val widgetId: String, + val sharedElements: List? = null, + val sharedStyles: List>? = null, +) + +val LocalVoltraRenderContext = + compositionLocalOf { + error("VoltraRenderContext not provided") + } diff --git a/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt b/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt new file mode 100644 index 0000000..570a219 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt @@ -0,0 +1,213 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.glance.ButtonDefaults +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.FilledButton +import androidx.glance.appwidget.components.OutlineButton +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.layout.Box +import androidx.glance.unit.ColorProvider +import com.google.gson.Gson +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.styling.JSColorParser + +private const val TAG = "ButtonRenderers" +private val gson = Gson() + +@Composable +fun RenderButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + Box(modifier = finalModifier) { + RenderNode(element.c) + } +} + +@Composable +fun RenderFilledButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, context.sharedStyles).modifier + + val componentId = element.i ?: "button_${element.hashCode()}" + val text = (element.p?.get("text") as? String) ?: "" + val enabled = (element.p?.get("enabled") as? Boolean) ?: true + val maxLines = (element.p?.get("maxLines") as? Number)?.toInt() ?: Int.MAX_VALUE + val icon = extractImageProvider(element.p?.get("icon")) + + val backgroundColor = element.p?.get("backgroundColor") as? String + val contentColor = element.p?.get("contentColor") as? String + + val colors = + if (backgroundColor != null && contentColor != null) { + val bg = JSColorParser.parse(backgroundColor) + val fg = JSColorParser.parse(contentColor) + if (bg != null && fg != null) { + ButtonDefaults.buttonColors( + backgroundColor = ColorProvider(bg), + contentColor = ColorProvider(fg), + ) + } else { + ButtonDefaults.buttonColors() + } + } else { + ButtonDefaults.buttonColors() + } + + FilledButton( + text = text, + onClick = getOnClickAction(LocalContext.current, element.p, context.widgetId, componentId), + modifier = computedModifier, + enabled = enabled, + icon = icon.takeIf { element.p?.containsKey("icon") == true }, // Only pass icon if present + colors = colors, + maxLines = maxLines, + ) +} + +@Composable +fun RenderOutlineButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, context.sharedStyles).modifier + + val componentId = element.i ?: "button_${element.hashCode()}" + val text = (element.p?.get("text") as? String) ?: extractTextFromNode(element.c) + val enabled = (element.p?.get("enabled") as? Boolean) ?: true + val maxLines = (element.p?.get("maxLines") as? Number)?.toInt() ?: Int.MAX_VALUE + val icon = extractImageProvider(element.p?.get("icon")) + + val contentColorProp = element.p?.get("contentColor") as? String + val contentColor = + if (contentColorProp != null) { + JSColorParser.parse(contentColorProp)?.let { ColorProvider(it) } ?: ColorProvider(Color.Black) + } else { + ColorProvider(Color.Black) + } + + OutlineButton( + text = text, + contentColor = contentColor, + onClick = getOnClickAction(LocalContext.current, element.p, context.widgetId, componentId), + modifier = computedModifier, + enabled = enabled, + icon = icon.takeIf { element.p?.containsKey("icon") == true }, + maxLines = maxLines, + ) +} + +@Composable +fun RenderCircleIconButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, context.sharedStyles).modifier + + val componentId = element.i ?: "button_${element.hashCode()}" + val contentDescription = (element.p?.get("contentDescription") as? String) ?: "" + val imageProvider = extractImageProvider(element.p?.get("icon")) ?: ImageProvider(android.R.drawable.ic_menu_add) + val enabled = (element.p?.get("enabled") as? Boolean) ?: true + + val backgroundColorProp = element.p?.get("backgroundColor") as? String + val contentColorProp = element.p?.get("contentColor") as? String + + val backgroundColor = + if (backgroundColorProp != null) { + JSColorParser.parse(backgroundColorProp)?.let { ColorProvider(it) } + } else { + androidx.glance.GlanceTheme.colors.background + } + + val contentColor = + if (contentColorProp != null) { + JSColorParser.parse(contentColorProp)?.let { ColorProvider(it) } + } else { + androidx.glance.GlanceTheme.colors.onSurface + } + + CircleIconButton( + imageProvider = imageProvider, + contentDescription = contentDescription, + onClick = getOnClickAction(LocalContext.current, element.p, context.widgetId, componentId), + modifier = computedModifier, + enabled = enabled, + backgroundColor = backgroundColor, + contentColor = contentColor ?: androidx.glance.GlanceTheme.colors.onSurface, + ) +} + +@Composable +fun RenderSquareIconButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, context.sharedStyles).modifier + + val componentId = element.i ?: "button_${element.hashCode()}" + val contentDescription = (element.p?.get("contentDescription") as? String) ?: "" + val imageProvider = extractImageProvider(element.p?.get("icon")) ?: ImageProvider(android.R.drawable.ic_menu_add) + val enabled = (element.p?.get("enabled") as? Boolean) ?: true + + val backgroundColorProp = element.p?.get("backgroundColor") as? String + val contentColorProp = element.p?.get("contentColor") as? String + + val backgroundColor = + if (backgroundColorProp != null) { + JSColorParser.parse(backgroundColorProp)?.let { ColorProvider(it) } + } else { + androidx.glance.GlanceTheme.colors.primary + } + + val contentColor = + if (contentColorProp != null) { + JSColorParser.parse(contentColorProp)?.let { ColorProvider(it) } + } else { + androidx.glance.GlanceTheme.colors.onPrimary + } + + SquareIconButton( + imageProvider = imageProvider, + contentDescription = contentDescription, + onClick = getOnClickAction(LocalContext.current, element.p, context.widgetId, componentId), + modifier = computedModifier, + enabled = enabled, + backgroundColor = backgroundColor ?: androidx.glance.GlanceTheme.colors.primary, + contentColor = contentColor ?: androidx.glance.GlanceTheme.colors.onPrimary, + ) +} + +private fun extractTextFromNode(node: voltra.models.VoltraNode?): String = + when (node) { + is voltra.models.VoltraNode.Text -> node.text + is voltra.models.VoltraNode.Array -> { + node.elements.filterIsInstance().joinToString("") { it.text } + } + else -> "" + } diff --git a/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt b/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt new file mode 100644 index 0000000..7f18bc6 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt @@ -0,0 +1,173 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.text.FontFamily +import androidx.glance.unit.ColorProvider +import com.google.gson.Gson +import voltra.ComponentRegistry +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.styling.JSColorParser + +private const val TAG = "ComplexRenderers" +private val gson = Gson() + +@Composable +fun RenderTitleBar( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalContext.current + val renderContext = LocalVoltraRenderContext.current + val (computedModifier, _) = resolveAndApplyStyle(element.p, renderContext.sharedStyles) + val modifierWithClickable = + applyClickableIfNeeded( + computedModifier, + element.p, + element.i, + renderContext.widgetId, + element.t, + element.hashCode(), + ) + val finalModifier = modifier ?: modifierWithClickable + + val title = (element.p?.get("title") as? String) ?: "" + val startIcon = extractImageProvider(element.p?.get("startIcon")) ?: ImageProvider(android.R.drawable.ic_menu_add) + + val textColor = + element.p?.get("textColor")?.let { + JSColorParser.parse(it as String)?.let { color -> + ColorProvider(color) + } + } ?: androidx.glance.GlanceTheme.colors.onSurface + + val iconColor = + element.p?.get("iconColor")?.let { + JSColorParser.parse(it as String)?.let { color -> + ColorProvider(color) + } + } ?: androidx.glance.GlanceTheme.colors.onSurface + + val fontFamilyString = element.p?.get("fontFamily") as? String + val fontFamily = + if (fontFamilyString != null) { + when (fontFamilyString) { + "monospace" -> FontFamily.Monospace + "serif" -> FontFamily.Serif + "sans-serif" -> FontFamily.SansSerif + "cursive" -> FontFamily.Cursive + else -> null + } + } else { + null + } + + TitleBar( + startIcon = startIcon, + title = title, + textColor = textColor, + iconColor = iconColor, + modifier = finalModifier, + fontFamily = fontFamily, + ) { + RenderNode(element.c) + } +} + +@Composable +fun RenderScaffold( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val renderContext = LocalVoltraRenderContext.current + val (computedModifier, _) = resolveAndApplyStyle(element.p, renderContext.sharedStyles) + val modifierWithClickable = + applyClickableIfNeeded( + computedModifier, + element.p, + element.i, + renderContext.widgetId, + element.t, + element.hashCode(), + ) + val finalModifier = modifier ?: modifierWithClickable + + val backgroundColor = + element.p?.get("backgroundColor")?.let { + JSColorParser.parse(it as String)?.let { color -> + ColorProvider(color) + } + } ?: androidx.glance.GlanceTheme.colors.widgetBackground + + val horizontalPadding = (element.p?.get("horizontalPadding") as? Number)?.toFloat()?.dp ?: 12.dp + + // Find titleBar element and body content separately + val (titleBarNode, bodyNode) = extractScaffoldChildren(element.c, renderContext) + + Scaffold( + modifier = finalModifier, + backgroundColor = backgroundColor, + horizontalPadding = horizontalPadding, + titleBar = { + if (titleBarNode != null) { + RenderNode(titleBarNode) + } + }, + ) { + if (bodyNode != null) { + RenderNode(bodyNode) + } + } +} + +private fun extractScaffoldChildren( + children: VoltraNode?, + context: voltra.glance.VoltraRenderContext, +): Pair = + when (children) { + is VoltraNode.Array -> { + val titleBar = + children.elements.find { child -> + when (child) { + is VoltraNode.Element -> child.element.t == ComponentRegistry.TITLE_BAR + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + resolved is VoltraNode.Element && resolved.element.t == ComponentRegistry.TITLE_BAR + } + else -> false + } + } + + val bodyElements = + children.elements.filter { child -> + when (child) { + is VoltraNode.Element -> child.element.t != ComponentRegistry.TITLE_BAR + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + !(resolved is VoltraNode.Element && resolved.element.t == ComponentRegistry.TITLE_BAR) + } + else -> true + } + } + val body = if (bodyElements.isNotEmpty()) VoltraNode.Array(bodyElements) else null + + titleBar to body + } + is VoltraNode.Element -> { + if (children.element.t == ComponentRegistry.TITLE_BAR) { + children to null + } else { + null to children + } + } + else -> null to children + } diff --git a/android/src/main/java/voltra/glance/renderers/InputRenderers.kt b/android/src/main/java/voltra/glance/renderers/InputRenderers.kt new file mode 100644 index 0000000..d1ca4f0 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/InputRenderers.kt @@ -0,0 +1,180 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.appwidget.CheckBox +import androidx.glance.appwidget.CheckboxDefaults +import androidx.glance.appwidget.RadioButton +import androidx.glance.appwidget.RadioButtonDefaults +import androidx.glance.appwidget.Switch +import androidx.glance.appwidget.SwitchDefaults +import androidx.glance.unit.ColorProvider +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.styling.JSColorParser +import voltra.styling.StyleConverter +import voltra.styling.toGlanceTextStyle + +@Composable +fun RenderSwitch( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = androidx.glance.LocalContext.current + val renderContext = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, renderContext.sharedStyles).modifier + + val componentId = element.i ?: "switch_${element.hashCode()}" + val checked = (element.p?.get("checked") as? Boolean) ?: false + + val text = (element.p?.get("text") as? String) ?: "" + val maxLines = (element.p?.get("maxLines") as? Number)?.toInt() ?: Int.MAX_VALUE + + val styleMap = element.p?.get("style") as? Map + val textStyle = + if (styleMap != null) { + StyleConverter.convert(styleMap).text.toGlanceTextStyle() + } else { + null + } + + val thumbChecked = element.p?.get("thumbCheckedColor") as? String + val thumbUnchecked = element.p?.get("thumbUncheckedColor") as? String + val trackChecked = element.p?.get("trackCheckedColor") as? String + val trackUnchecked = element.p?.get("trackUncheckedColor") as? String + + val colors = + if (thumbChecked != null && thumbUnchecked != null && trackChecked != null && trackUnchecked != null) { + val tc = JSColorParser.parse(thumbChecked) + val tuc = JSColorParser.parse(thumbUnchecked) + val trc = JSColorParser.parse(trackChecked) + val truc = JSColorParser.parse(trackUnchecked) + + if (tc != null && tuc != null && trc != null && truc != null) { + SwitchDefaults.colors( + checkedThumbColor = ColorProvider(tc), + uncheckedThumbColor = ColorProvider(tuc), + checkedTrackColor = ColorProvider(trc), + uncheckedTrackColor = ColorProvider(truc), + ) + } else { + SwitchDefaults.colors() + } + } else { + SwitchDefaults.colors() + } + + Switch( + checked = checked, + onCheckedChange = getOnClickAction(LocalContext.current, element.p, renderContext.widgetId, componentId), + modifier = computedModifier, + text = text, + style = textStyle, + maxLines = maxLines, + colors = colors, + ) +} + +@Composable +fun RenderRadioButton( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = androidx.glance.LocalContext.current + val renderContext = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, renderContext.sharedStyles).modifier + + val componentId = element.i ?: "radio_${element.hashCode()}" + val checked = (element.p?.get("checked") as? Boolean) ?: false + + val text = (element.p?.get("text") as? String) ?: "" + val maxLines = (element.p?.get("maxLines") as? Number)?.toInt() ?: Int.MAX_VALUE + val enabled = (element.p?.get("enabled") as? Boolean) ?: true + + val styleMap = element.p?.get("style") as? Map + val textStyle = + if (styleMap != null) { + StyleConverter.convert(styleMap).text.toGlanceTextStyle() + } else { + null + } + + val checkedColor = element.p?.get("checkedColor") as? String + val uncheckedColor = element.p?.get("uncheckedColor") as? String + + val colors = + if (checkedColor != null && uncheckedColor != null) { + val c = JSColorParser.parse(checkedColor) + val uc = JSColorParser.parse(uncheckedColor) + if (c != null && uc != null) { + RadioButtonDefaults.colors(ColorProvider(c), ColorProvider(uc)) + } else { + RadioButtonDefaults.colors() + } + } else { + RadioButtonDefaults.colors() + } + + RadioButton( + checked = checked, + onClick = getOnClickAction(LocalContext.current, element.p, renderContext.widgetId, componentId), + modifier = computedModifier, + enabled = enabled, + text = text, + style = textStyle, + maxLines = maxLines, + colors = colors, + ) +} + +@Composable +fun RenderCheckBox( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = androidx.glance.LocalContext.current + val renderContext = LocalVoltraRenderContext.current + val computedModifier = modifier ?: resolveAndApplyStyle(element.p, renderContext.sharedStyles).modifier + + val componentId = element.i ?: "checkbox_${element.hashCode()}" + val checked = (element.p?.get("checked") as? Boolean) ?: false + + val text = (element.p?.get("text") as? String) ?: "" + val maxLines = (element.p?.get("maxLines") as? Number)?.toInt() ?: Int.MAX_VALUE + + val styleMap = element.p?.get("style") as? Map + val textStyle = + if (styleMap != null) { + StyleConverter.convert(styleMap).text.toGlanceTextStyle() + } else { + null + } + + val checkedColor = element.p?.get("checkedColor") as? String + val uncheckedColor = element.p?.get("uncheckedColor") as? String + + val colors = + if (checkedColor != null && uncheckedColor != null) { + val c = JSColorParser.parse(checkedColor) + val uc = JSColorParser.parse(uncheckedColor) + if (c != null && uc != null) { + CheckboxDefaults.colors(ColorProvider(c), ColorProvider(uc)) + } else { + CheckboxDefaults.colors() + } + } else { + CheckboxDefaults.colors() + } + + CheckBox( + checked = checked, + onCheckedChange = getOnClickAction(LocalContext.current, element.p, renderContext.widgetId, componentId), + modifier = computedModifier, + text = text, + style = textStyle, + maxLines = maxLines, + colors = colors, + ) +} diff --git a/android/src/main/java/voltra/glance/renderers/LayoutRenderers.kt b/android/src/main/java/voltra/glance/renderers/LayoutRenderers.kt new file mode 100644 index 0000000..2baca81 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/LayoutRenderers.kt @@ -0,0 +1,241 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.layout.* +import androidx.glance.text.Text +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.VoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.styling.applyFlex + +@Composable +fun RenderColumn( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val horizontalAlignment = + when (element.p?.get("horizontalAlignment") as? String) { + "start" -> Alignment.Horizontal.Start + "center-horizontally" -> Alignment.Horizontal.CenterHorizontally + "end" -> Alignment.Horizontal.End + else -> Alignment.Horizontal.Start + } + + val verticalAlignment = + when (element.p?.get("verticalAlignment") as? String) { + "top" -> Alignment.Vertical.Top + "center-vertically" -> Alignment.Vertical.CenterVertically + "bottom" -> Alignment.Vertical.Bottom + else -> Alignment.Vertical.Top + } + + Column( + modifier = finalModifier, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + ) { + when (val children = element.c) { + is VoltraNode.Array -> { + children.elements.forEach { child -> + RenderChildWithWeight(child) + } + } + else -> { + RenderChildWithWeight(children) + } + } + } +} + +@Composable +fun RenderRow( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val horizontalAlignment = + when (element.p?.get("horizontalAlignment") as? String) { + "start" -> Alignment.Horizontal.Start + "center-horizontally" -> Alignment.Horizontal.CenterHorizontally + "end" -> Alignment.Horizontal.End + else -> Alignment.Horizontal.Start + } + + val verticalAlignment = + when (element.p?.get("verticalAlignment") as? String) { + "top" -> Alignment.Vertical.Top + "center-vertically" -> Alignment.Vertical.CenterVertically + "bottom" -> Alignment.Vertical.Bottom + else -> Alignment.Vertical.CenterVertically + } + + Row( + modifier = finalModifier, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + ) { + when (val children = element.c) { + is VoltraNode.Array -> { + children.elements.forEach { child -> + RenderChildWithWeight(child) + } + } + else -> { + RenderChildWithWeight(children) + } + } + } +} + +@Composable +fun RenderBox( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val contentAlignment = + when (element.p?.get("contentAlignment") as? String) { + "top-start" -> Alignment.TopStart + "top-center" -> Alignment.TopCenter + "top-end" -> Alignment.TopEnd + "center-start" -> Alignment.CenterStart + "center" -> Alignment.Center + "center-end" -> Alignment.CenterEnd + "bottom-start" -> Alignment.BottomStart + "bottom-center" -> Alignment.BottomCenter + "bottom-end" -> Alignment.BottomEnd + else -> Alignment.TopStart + } + + Box( + modifier = finalModifier, + contentAlignment = contentAlignment, + ) { + RenderNode(element.c) + } +} + +@Composable +fun RenderSpacer( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + Spacer(modifier = finalModifier) +} + +// Helper extension functions for scope-dependent rendering + +@Composable +private fun ColumnScope.RenderChildWithWeight(child: VoltraNode?) { + if (child == null) return + + val context = LocalVoltraRenderContext.current + val weight = extractWeightFromChild(child, context) + + when (child) { + is VoltraNode.Element -> { + val (baseModifier, compositeStyle) = resolveAndApplyStyle(child.element.p, context.sharedStyles) + val finalModifier = applyFlex(baseModifier, weight) + RenderElementWithModifier(child.element, finalModifier, compositeStyle) + } + is VoltraNode.Array -> { + child.elements.forEach { RenderChildWithWeight(it) } + } + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + RenderChildWithWeight(resolved) + } + is VoltraNode.Text -> Text(child.text) + } +} + +@Composable +private fun RowScope.RenderChildWithWeight(child: VoltraNode?) { + if (child == null) return + + val context = LocalVoltraRenderContext.current + val weight = extractWeightFromChild(child, context) + + when (child) { + is VoltraNode.Element -> { + val (baseModifier, compositeStyle) = resolveAndApplyStyle(child.element.p, context.sharedStyles) + val finalModifier = applyFlex(baseModifier, weight) + RenderElementWithModifier(child.element, finalModifier, compositeStyle) + } + is VoltraNode.Array -> { + child.elements.forEach { RenderChildWithWeight(it) } + } + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + RenderChildWithWeight(resolved) + } + is VoltraNode.Text -> Text(child.text) + } +} + +private fun extractWeightFromChild( + child: VoltraNode?, + context: voltra.glance.VoltraRenderContext, +): Float? { + val element = + when (child) { + is VoltraNode.Element -> child.element + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + if (resolved is VoltraNode.Element) resolved.element else null + } + else -> null + } ?: return null + + val (_, compositeStyle) = resolveAndApplyStyle(element.p, context.sharedStyles) + return compositeStyle?.layout?.weight +} diff --git a/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt b/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt new file mode 100644 index 0000000..09cad35 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt @@ -0,0 +1,137 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.lazy.GridCells +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.LazyVerticalGrid +import androidx.glance.layout.Alignment +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.models.VoltraNode + +@Composable +fun RenderLazyColumn( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + // Extract props + val horizontalAlignment = extractHorizontalAlignment(element.p) + + LazyColumn( + modifier = finalModifier, + horizontalAlignment = horizontalAlignment, + ) { + when (val children = element.c) { + is VoltraNode.Array -> { + items(children.elements.size) { index -> + RenderNode(children.elements[index]) + } + } + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(children.ref) + if (resolved is VoltraNode.Array) { + items(resolved.elements.size) { index -> + RenderNode(resolved.elements[index]) + } + } else { + item { RenderNode(resolved) } + } + } + null -> { /* Empty list */ } + else -> { + item { RenderNode(children) } + } + } + } +} + +@Composable +fun RenderLazyVerticalGrid( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + // Extract grid configuration from props + val gridCells = + when (val columns = element.p?.get("columns")) { + is Number -> GridCells.Fixed(columns.toInt()) + "adaptive" -> { + val minSize = (element.p?.get("minSize") as? Number)?.toInt() ?: 100 + GridCells.Adaptive(minSize.dp) + } + else -> GridCells.Fixed(2) // Default to 2 columns + } + + val horizontalAlignment = extractHorizontalAlignment(element.p) + + LazyVerticalGrid( + gridCells = gridCells, + modifier = finalModifier, + horizontalAlignment = horizontalAlignment, + ) { + when (val children = element.c) { + is VoltraNode.Array -> { + items(children.elements.size) { index -> + RenderNode(children.elements[index]) + } + } + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(children.ref) + if (resolved is VoltraNode.Array) { + items(resolved.elements.size) { index -> + RenderNode(resolved.elements[index]) + } + } else { + item { RenderNode(resolved) } + } + } + null -> { /* Empty grid */ } + else -> { + item { RenderNode(children) } + } + } + } +} + +private fun extractHorizontalAlignment(props: Map?): Alignment.Horizontal = + when (props?.get("horizontalAlignment") as? String) { + "start" -> Alignment.Horizontal.Start + "center-horizontally" -> Alignment.Horizontal.CenterHorizontally + "end" -> Alignment.Horizontal.End + else -> Alignment.Horizontal.Start + } + +private fun extractVerticalAlignment(props: Map?): Alignment.Vertical = + when (props?.get("verticalAlignment") as? String) { + "top" -> Alignment.Vertical.Top + "center" -> Alignment.Vertical.CenterVertically + "bottom" -> Alignment.Vertical.Bottom + else -> Alignment.Vertical.Top + } diff --git a/android/src/main/java/voltra/glance/renderers/ProgressRenderers.kt b/android/src/main/java/voltra/glance/renderers/ProgressRenderers.kt new file mode 100644 index 0000000..179ec4a --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ProgressRenderers.kt @@ -0,0 +1,109 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.LinearProgressIndicator +import androidx.glance.appwidget.ProgressIndicatorDefaults +import androidx.glance.unit.ColorProvider +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.styling.JSColorParser + +@Composable +fun RenderLinearProgress( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val progress = (element.p?.get("progress") as? Number)?.toFloat() + + val colorProp = element.p?.get("color") as? String + val backgroundColorProp = element.p?.get("backgroundColor") as? String + + val parsedColor = colorProp?.let { JSColorParser.parse(it) } + val color = + if (parsedColor != + null + ) { + ColorProvider(parsedColor) + } else { + ProgressIndicatorDefaults.IndicatorColorProvider + } + + val parsedBgColor = backgroundColorProp?.let { JSColorParser.parse(it) } + val backgroundColor = + if (parsedBgColor != + null + ) { + ColorProvider(parsedBgColor) + } else { + ProgressIndicatorDefaults.BackgroundColorProvider + } + + if (progress != null) { + // Determinate progress - preserves value across updates + LinearProgressIndicator( + progress = progress.coerceIn(0f, 1f), + modifier = finalModifier, + color = color, + backgroundColor = backgroundColor, + ) + } else { + // Indeterminate progress - animation will reset on each update + LinearProgressIndicator( + modifier = finalModifier, + color = color, + backgroundColor = backgroundColor, + ) + } +} + +@Composable +fun RenderCircularProgress( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val context = LocalVoltraRenderContext.current + val (baseModifier, _) = resolveAndApplyStyle(element.p, context.sharedStyles) + val finalModifier = + applyClickableIfNeeded( + modifier ?: baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val circularColorProp = element.p?.get("color") as? String + val parsedCircularColor = circularColorProp?.let { JSColorParser.parse(it) } + val circularColor = + if (parsedCircularColor != + null + ) { + ColorProvider(parsedCircularColor) + } else { + ProgressIndicatorDefaults.IndicatorColorProvider + } + + // Note: Glance's CircularProgressIndicator only supports indeterminate mode + // Animation will reset on each notification update - this is a platform limitation + CircularProgressIndicator( + modifier = finalModifier, + color = circularColor, + ) +} diff --git a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt new file mode 100644 index 0000000..8f52c06 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -0,0 +1,209 @@ +package voltra.glance.renderers + +import androidx.glance.action.Action +import androidx.glance.appwidget.action.actionStartActivity +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.layout.ContentScale +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import voltra.ComponentRegistry +import voltra.glance.LocalVoltraRenderContext +import voltra.images.VoltraImageManager +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.styling.CompositeStyle + +private val gson = Gson() +private const val TAG = "RenderCommon" + +fun getOnClickAction( + context: Context, + props: Map?, + widgetId: String, + componentId: String, +): Action { + val deepLinkUrl = props?.get("deepLinkUrl") as? String + + if (deepLinkUrl != null && deepLinkUrl.isNotEmpty()) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)) + intent.setPackage(context.packageName) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return actionStartActivity(intent) + } + + // Fallback: Start main activity + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + if (launchIntent != null) { + // Add extras so the app knows what was clicked + launchIntent.putExtra("widgetId", widgetId) + launchIntent.putExtra("componentId", componentId) + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return actionStartActivity(launchIntent) + } + + return actionStartActivity(Intent()) +} + +fun parseContentScale(value: String?): ContentScale = + when (value) { + "crop", "cover" -> ContentScale.Crop + "fit", "contain" -> ContentScale.Fit + "fill-bounds", "stretch" -> ContentScale.FillBounds + else -> ContentScale.Fit + } + +@Composable +fun extractImageProvider(sourceProp: Any?): ImageProvider? { + if (sourceProp == null) return null + + val context = LocalContext.current + val sourceMap = + when (sourceProp) { + is String -> { + try { + val type = object : TypeToken>() {}.type + gson.fromJson>(sourceProp, type) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse image source JSON: $sourceProp", e) + null + } + } + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + sourceProp as? Map + } + else -> null + } ?: return null + + val assetName = sourceMap["assetName"] as? String + val base64 = sourceMap["base64"] as? String + + if (assetName != null) { + // Try as drawable resource first + val resId = context.resources.getIdentifier(assetName, "drawable", context.packageName) + if (resId != 0) return ImageProvider(resId) + + // Try as preloaded asset + val imageManager = VoltraImageManager(context) + val uriString = imageManager.getUriForKey(assetName) + if (uriString != null) { + try { + val uri = Uri.parse(uriString) + context.contentResolver.openInputStream(uri)?.use { stream -> + val bitmap = BitmapFactory.decodeStream(stream) + return ImageProvider(bitmap) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to decode preloaded image: $assetName", e) + } + } + } + + if (base64 != null) { + try { + val decodedString = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size) + if (bitmap != null) { + return ImageProvider(bitmap) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64 image", e) + } + } + + return null +} + +/** + * Main dispatcher for rendering any VoltraNode. + * Handles Element, Array, Ref, Text, and null cases. + * Children rendering is delegated to RenderNode - each component doesn't need to care about node types. + */ +@Composable +fun RenderNode(node: VoltraNode?) { + val context = LocalVoltraRenderContext.current + + when (node) { + is VoltraNode.Element -> RenderElement(node.element) + is VoltraNode.Array -> { + node.elements.forEach { RenderNode(it) } + } + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(node.ref) + RenderNode(resolved) + } + is VoltraNode.Text -> androidx.glance.text.Text(node.text) + null -> { /* Empty */ } + } +} + +/** + * Router that dispatches to specific component renderers based on element type. + */ +@Composable +private fun RenderElement(element: VoltraElement) { + when (element.t) { + ComponentRegistry.TEXT -> RenderText(element) + ComponentRegistry.COLUMN -> RenderColumn(element) + ComponentRegistry.ROW -> RenderRow(element) + ComponentRegistry.BOX -> RenderBox(element) + ComponentRegistry.SPACER -> RenderSpacer(element) + ComponentRegistry.IMAGE -> RenderImage(element) + ComponentRegistry.BUTTON -> RenderButton(element) + ComponentRegistry.LINEAR_PROGRESS -> RenderLinearProgress(element) + ComponentRegistry.CIRCULAR_PROGRESS -> RenderCircularProgress(element) + ComponentRegistry.SWITCH -> RenderSwitch(element) + ComponentRegistry.RADIO_BUTTON -> RenderRadioButton(element) + ComponentRegistry.CHECK_BOX -> RenderCheckBox(element) + ComponentRegistry.FILLED_BUTTON -> RenderFilledButton(element) + ComponentRegistry.OUTLINE_BUTTON -> RenderOutlineButton(element) + ComponentRegistry.CIRCLE_ICON_BUTTON -> RenderCircleIconButton(element) + ComponentRegistry.SQUARE_ICON_BUTTON -> RenderSquareIconButton(element) + ComponentRegistry.TITLE_BAR -> RenderTitleBar(element) + ComponentRegistry.SCAFFOLD -> RenderScaffold(element) + ComponentRegistry.LAZY_COLUMN -> RenderLazyColumn(element) + ComponentRegistry.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element) + } +} + +/** + * Used when an element with a pre-computed modifier needs to be rendered. + * This is used when we need to apply weight separately from other styles (in scopes). + */ +@Composable +fun RenderElementWithModifier( + element: VoltraElement, + modifier: GlanceModifier, + compositeStyle: CompositeStyle?, +) { + when (element.t) { + ComponentRegistry.TEXT -> RenderText(element, modifier, compositeStyle) + ComponentRegistry.COLUMN -> RenderColumn(element, modifier) + ComponentRegistry.ROW -> RenderRow(element, modifier) + ComponentRegistry.BOX -> RenderBox(element, modifier) + ComponentRegistry.SPACER -> RenderSpacer(element, modifier) + ComponentRegistry.IMAGE -> RenderImage(element, modifier) + ComponentRegistry.BUTTON -> RenderButton(element, modifier) + ComponentRegistry.LINEAR_PROGRESS -> RenderLinearProgress(element, modifier) + ComponentRegistry.CIRCULAR_PROGRESS -> RenderCircularProgress(element, modifier) + ComponentRegistry.SWITCH -> RenderSwitch(element, modifier) + ComponentRegistry.RADIO_BUTTON -> RenderRadioButton(element, modifier) + ComponentRegistry.CHECK_BOX -> RenderCheckBox(element, modifier) + ComponentRegistry.FILLED_BUTTON -> RenderFilledButton(element, modifier) + ComponentRegistry.OUTLINE_BUTTON -> RenderOutlineButton(element, modifier) + ComponentRegistry.CIRCLE_ICON_BUTTON -> RenderCircleIconButton(element, modifier) + ComponentRegistry.SQUARE_ICON_BUTTON -> RenderSquareIconButton(element, modifier) + ComponentRegistry.TITLE_BAR -> RenderTitleBar(element, modifier) + ComponentRegistry.SCAFFOLD -> RenderScaffold(element, modifier) + ComponentRegistry.LAZY_COLUMN -> RenderLazyColumn(element, modifier) + ComponentRegistry.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element, modifier) + } +} diff --git a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt new file mode 100644 index 0000000..6651221 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -0,0 +1,108 @@ +package voltra.glance.renderers + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.LocalContext +import androidx.glance.text.Text +import com.google.gson.Gson +import voltra.glance.LocalVoltraRenderContext +import voltra.glance.ResolvedStyle +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.styling.toGlanceTextStyle + +private const val TAG = "TextAndImageRenderers" +private val gson = Gson() + +@Composable +fun RenderText( + element: VoltraElement, + modifier: GlanceModifier? = null, + compositeStyle: voltra.styling.CompositeStyle? = null, +) { + val context = LocalVoltraRenderContext.current + val baseModifier = modifier ?: resolveAndApplyStyle(element.p, context.sharedStyles).modifier + val finalModifier = + applyClickableIfNeeded( + baseModifier, + element.p, + element.i, + context.widgetId, + element.t, + element.hashCode(), + ) + + val resolvedStyle = + if (compositeStyle != null) { + compositeStyle + } else { + resolveAndApplyStyle(element.p, context.sharedStyles).compositeStyle + } + + val text = extractTextFromNode(element.c) + val textStyle = resolvedStyle?.text?.toGlanceTextStyle() ?: androidx.glance.text.TextStyle() + + Text(text = text, modifier = finalModifier, style = textStyle) +} + +@Composable +fun RenderImage( + element: VoltraElement, + modifier: GlanceModifier? = null, +) { + val renderContext = LocalVoltraRenderContext.current + val baseModifier = modifier ?: resolveAndApplyStyle(element.p, renderContext.sharedStyles).modifier + val finalModifier = + applyClickableIfNeeded( + baseModifier, + element.p, + element.i, + renderContext.widgetId, + element.t, + element.hashCode(), + ) + + val contentDescription = element.p?.get("contentDescription") as? String + val contentScale = + parseContentScale( + (element.p?.get("contentScale") as? String) ?: (element.p?.get("resizeMode") as? String), + ) + val alpha = (element.p?.get("alpha") as? Number)?.toFloat() ?: 1.0f + + val tintColorString = element.p?.get("tintColor") as? String + val colorFilter = + if (tintColorString != null) { + voltra.styling.JSColorParser.parse(tintColorString)?.let { + androidx.glance.ColorFilter.tint(androidx.glance.unit.ColorProvider(it)) + } + } else { + null + } + + val imageProvider = extractImageProvider(element.p?.get("source")) + + if (imageProvider != null) { + Image( + provider = imageProvider, + contentDescription = contentDescription, + modifier = finalModifier, + contentScale = contentScale, + colorFilter = colorFilter, + alpha = alpha, + ) + } else { + androidx.glance.layout.Box(modifier = finalModifier) {} + } +} + +private fun extractTextFromNode(node: VoltraNode?): String = + when (node) { + is VoltraNode.Text -> node.text + is VoltraNode.Array -> { + node.elements.filterIsInstance().joinToString("") { it.text } + } + else -> "" + } diff --git a/android/src/main/java/voltra/images/VoltraImageManager.kt b/android/src/main/java/voltra/images/VoltraImageManager.kt new file mode 100644 index 0000000..e06c6c6 --- /dev/null +++ b/android/src/main/java/voltra/images/VoltraImageManager.kt @@ -0,0 +1,123 @@ +package voltra.images + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +class VoltraImageManager( + private val context: Context, +) { + companion object { + private const val TAG = "VoltraImageManager" + private const val PREFS_NAME = "voltra_preload_images" + private const val CACHE_DIR_NAME = "voltra_widget_images" + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + suspend fun preloadImage( + url: String, + key: String, + method: String = "GET", + headers: Map? = null, + ): String? = + withContext(Dispatchers.IO) { + try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = method + headers?.forEach { (k, v) -> connection.setRequestProperty(k, v) } + + connection.connect() + if (connection.responseCode !in 200..299) { + Log.e(TAG, "Failed to download image: ${connection.responseCode}") + return@withContext null + } + + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + + // Append timestamp to force refresh + val filename = "${key}_${System.currentTimeMillis()}.png" + val file = File(cacheDir, filename) + + connection.inputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + + val uri = + FileProvider + .getUriForFile( + context, + "${context.packageName}.voltra.fileprovider", + file, + ).toString() + + // Delete old file if exists + getUriForKey(key)?.let { oldUriString -> + try { + val oldUri = Uri.parse(oldUriString) + context.contentResolver.delete(oldUri, null, null) + // Also try to delete the file directly just in case + val oldFilename = oldUri.lastPathSegment + if (oldFilename != null) { + File(cacheDir, oldFilename).delete() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete old image file: $oldUriString", e) + } + } + + prefs.edit().putString(key, uri).apply() + return@withContext key + } catch (e: Exception) { + Log.e(TAG, "Error preloading image: $key", e) + return@withContext null + } + } + + fun getUriForKey(key: String): String? = prefs.getString(key, null) + + fun clearPreloadedImages(keys: List?) { + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) + if (keys == null) { + // Clear all + prefs.all.keys.forEach { key -> + deleteFileForKey(key, cacheDir) + } + prefs.edit().clear().apply() + } else { + // Clear specific keys + keys.forEach { key -> + deleteFileForKey(key, cacheDir) + prefs.edit().remove(key).apply() + } + } + } + + private fun deleteFileForKey( + key: String, + cacheDir: File, + ) { + getUriForKey(key)?.let { uriString -> + try { + val uri = Uri.parse(uriString) + val filename = uri.lastPathSegment + if (filename != null) { + File(cacheDir, filename).delete() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete file for key: $key", e) + } + } + } +} diff --git a/android/src/main/java/voltra/models/VoltraPayload.kt b/android/src/main/java/voltra/models/VoltraPayload.kt new file mode 100644 index 0000000..b41010b --- /dev/null +++ b/android/src/main/java/voltra/models/VoltraPayload.kt @@ -0,0 +1,53 @@ +package voltra.models + +/** + * Root payload for both Live Updates and Widgets + */ +data class VoltraPayload( + val v: Int, // Version + val collapsed: VoltraNode? = null, // Collapsed content (Live Updates) + val expanded: VoltraNode? = null, // Expanded content (Live Updates) + val variants: Map? = null, // Size variants (Widgets) + val s: List>? = null, // Shared styles + val e: List? = null, // Shared elements + val smallIcon: String? = null, + val channelId: String? = null, +) + +/** + * Element matching VoltraElementJson { t, i?, c?, p? } + */ +data class VoltraElement( + val t: Int, // Component type ID + val i: String? = null, // Optional ID + val c: VoltraNode? = null, // Children + val p: Map? = null, // Props including style +) + +/** + * Reference to shared element { $r: index } + */ +data class VoltraElementRef( + val `$r`: Int, +) + +/** + * Union type for nodes: Element | Array | Ref | String + */ +sealed class VoltraNode { + data class Element( + val element: VoltraElement, + ) : VoltraNode() + + data class Array( + val elements: List, + ) : VoltraNode() + + data class Ref( + val ref: Int, + ) : VoltraNode() + + data class Text( + val text: String, + ) : VoltraNode() +} diff --git a/android/src/main/java/voltra/models/parameters/AndroidBoxParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidBoxParameters.kt new file mode 100644 index 0000000..8fcf89f --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidBoxParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidBoxParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidBox component + * Android Box container + */ +@Serializable +data class AndroidBoxParameters( + /** Content alignment within the box */ + val contentAlignment: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidButtonParameters.kt new file mode 100644 index 0000000..cc055e6 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidButtonParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidButton component + * Android Button component + */ +@Serializable +data class AndroidButtonParameters( + /** Whether the button is enabled */ + val enabled: Boolean? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidCheckBoxParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidCheckBoxParameters.kt new file mode 100644 index 0000000..c6e0d38 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidCheckBoxParameters.kt @@ -0,0 +1,26 @@ +// +// AndroidCheckBoxParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidCheckBox component + * Android CheckBox component + */ +@Serializable +data class AndroidCheckBoxParameters( + /** Unique identifier for interaction events */ + val id: String, + + /** Initial checked state */ + val checked: Boolean? = null, + + /** Whether the checkbox is enabled */ + val enabled: Boolean? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidCircleIconButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidCircleIconButtonParameters.kt new file mode 100644 index 0000000..7503f94 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidCircleIconButtonParameters.kt @@ -0,0 +1,32 @@ +// +// AndroidCircleIconButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidCircleIconButton component + * Android Circle Icon Button component + */ +@Serializable +data class AndroidCircleIconButtonParameters( + /** Icon source */ + val icon: String, + + /** Accessibility description */ + val contentDescription: String? = null, + + /** Whether the button is enabled */ + val enabled: Boolean? = null, + + /** Background color */ + val backgroundColor: String? = null, + + /** Icon color */ + val contentColor: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidCircularProgressIndicatorParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidCircularProgressIndicatorParameters.kt new file mode 100644 index 0000000..5f1af77 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidCircularProgressIndicatorParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidCircularProgressIndicatorParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidCircularProgressIndicator component + * Android Circular Progress Indicator + */ +@Serializable +data class AndroidCircularProgressIndicatorParameters( + /** Progress color */ + val color: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidColumnParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidColumnParameters.kt new file mode 100644 index 0000000..40732b0 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidColumnParameters.kt @@ -0,0 +1,23 @@ +// +// AndroidColumnParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidColumn component + * Android Column container + */ +@Serializable +data class AndroidColumnParameters( + /** Horizontal alignment of children */ + val horizontalAlignment: String? = null, + + /** Vertical alignment of children */ + val verticalAlignment: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidFilledButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidFilledButtonParameters.kt new file mode 100644 index 0000000..cf34d05 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidFilledButtonParameters.kt @@ -0,0 +1,35 @@ +// +// AndroidFilledButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidFilledButton component + * Android Material Design filled button component for widgets + */ +@Serializable +data class AndroidFilledButtonParameters( + /** Text to display */ + val text: String, + + /** Whether the button is enabled */ + val enabled: Boolean? = null, + + /** Optional icon */ + val icon: String? = null, + + /** Background color */ + val backgroundColor: String? = null, + + /** Text/icon color */ + val contentColor: String? = null, + + /** Maximum lines for text */ + val maxLines: Double? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt new file mode 100644 index 0000000..ea785de --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt @@ -0,0 +1,23 @@ +// +// AndroidImageParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidImage component + * Android Image component + */ +@Serializable +data class AndroidImageParameters( + /** Image source */ + val source: String, + + /** Resizing mode */ + val resizeMode: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidLazyColumnParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidLazyColumnParameters.kt new file mode 100644 index 0000000..9d299c9 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidLazyColumnParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidLazyColumnParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidLazyColumn component + * Android LazyColumn container + */ +@Serializable +data class AndroidLazyColumnParameters( + /** Horizontal alignment of children */ + val horizontalAlignment: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidLazyVerticalGridParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidLazyVerticalGridParameters.kt new file mode 100644 index 0000000..bac9a1e --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidLazyVerticalGridParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidLazyVerticalGridParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidLazyVerticalGrid component + * Android LazyVerticalGrid container + */ +@Serializable +data class AndroidLazyVerticalGridParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidLinearProgressIndicatorParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidLinearProgressIndicatorParameters.kt new file mode 100644 index 0000000..8f9433b --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidLinearProgressIndicatorParameters.kt @@ -0,0 +1,23 @@ +// +// AndroidLinearProgressIndicatorParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidLinearProgressIndicator component + * Android Linear Progress Indicator + */ +@Serializable +data class AndroidLinearProgressIndicatorParameters( + /** Progress color */ + val color: String? = null, + + /** Track background color */ + val backgroundColor: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidOutlineButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidOutlineButtonParameters.kt new file mode 100644 index 0000000..09fb94a --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidOutlineButtonParameters.kt @@ -0,0 +1,32 @@ +// +// AndroidOutlineButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidOutlineButton component + * Android Outline Button component + */ +@Serializable +data class AndroidOutlineButtonParameters( + /** Text to display */ + val text: String, + + /** Whether the button is enabled */ + val enabled: Boolean? = null, + + /** Optional icon */ + val icon: String? = null, + + /** Text/icon color */ + val contentColor: String? = null, + + /** Maximum lines for text */ + val maxLines: Double? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidRadioButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidRadioButtonParameters.kt new file mode 100644 index 0000000..478556e --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidRadioButtonParameters.kt @@ -0,0 +1,26 @@ +// +// AndroidRadioButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidRadioButton component + * Android RadioButton component + */ +@Serializable +data class AndroidRadioButtonParameters( + /** Unique identifier for interaction events */ + val id: String, + + /** Initial checked state */ + val checked: Boolean? = null, + + /** Whether the radio button is enabled */ + val enabled: Boolean? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidRowParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidRowParameters.kt new file mode 100644 index 0000000..a281ac5 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidRowParameters.kt @@ -0,0 +1,23 @@ +// +// AndroidRowParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidRow component + * Android Row container + */ +@Serializable +data class AndroidRowParameters( + /** Horizontal alignment of children */ + val horizontalAlignment: String? = null, + + /** Vertical alignment of children */ + val verticalAlignment: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidScaffoldParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidScaffoldParameters.kt new file mode 100644 index 0000000..5c6bd69 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidScaffoldParameters.kt @@ -0,0 +1,23 @@ +// +// AndroidScaffoldParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidScaffold component + * Android Scaffold container + */ +@Serializable +data class AndroidScaffoldParameters( + /** Background color */ + val backgroundColor: String? = null, + + /** Horizontal padding */ + val horizontalPadding: Double? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidSpacerParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidSpacerParameters.kt new file mode 100644 index 0000000..47143b6 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidSpacerParameters.kt @@ -0,0 +1,20 @@ +// +// AndroidSpacerParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidSpacer component + * Android Spacer component + */ +@Serializable +data class AndroidSpacerParameters( + /** Dummy parameter to satisfy data class requirements */ + val _dummy: Unit = Unit +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidSquareIconButtonParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidSquareIconButtonParameters.kt new file mode 100644 index 0000000..22dc094 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidSquareIconButtonParameters.kt @@ -0,0 +1,32 @@ +// +// AndroidSquareIconButtonParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidSquareIconButton component + * Android Square Icon Button component + */ +@Serializable +data class AndroidSquareIconButtonParameters( + /** Icon source */ + val icon: String, + + /** Accessibility description */ + val contentDescription: String? = null, + + /** Whether the button is enabled */ + val enabled: Boolean? = null, + + /** Background color */ + val backgroundColor: String? = null, + + /** Icon color */ + val contentColor: String? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidSwitchParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidSwitchParameters.kt new file mode 100644 index 0000000..529f2c8 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidSwitchParameters.kt @@ -0,0 +1,26 @@ +// +// AndroidSwitchParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidSwitch component + * Android Switch component + */ +@Serializable +data class AndroidSwitchParameters( + /** Unique identifier for interaction events */ + val id: String, + + /** Initial checked state */ + val checked: Boolean? = null, + + /** Whether the switch is enabled */ + val enabled: Boolean? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidTextParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidTextParameters.kt new file mode 100644 index 0000000..60180ee --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidTextParameters.kt @@ -0,0 +1,29 @@ +// +// AndroidTextParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidText component + * Android Text component + */ +@Serializable +data class AndroidTextParameters( + /** Text content */ + val text: String, + + /** Text color */ + val color: String? = null, + + /** Font size */ + val fontSize: Double? = null, + + /** Maximum lines */ + val maxLines: Double? = null +) diff --git a/android/src/main/java/voltra/models/parameters/AndroidTitleBarParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidTitleBarParameters.kt new file mode 100644 index 0000000..f1764df --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidTitleBarParameters.kt @@ -0,0 +1,26 @@ +// +// AndroidTitleBarParameters.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.models.parameters + +import kotlinx.serialization.Serializable + +/** + * Parameters for AndroidTitleBar component + * Android Title Bar component + */ +@Serializable +data class AndroidTitleBarParameters( + /** Title text */ + val title: String, + + /** Background color */ + val backgroundColor: String? = null, + + /** Text color */ + val contentColor: String? = null +) diff --git a/android/src/main/java/voltra/parsing/VoltraDecompressor.kt b/android/src/main/java/voltra/parsing/VoltraDecompressor.kt new file mode 100644 index 0000000..b9292b5 --- /dev/null +++ b/android/src/main/java/voltra/parsing/VoltraDecompressor.kt @@ -0,0 +1,58 @@ +package voltra.parsing + +import voltra.generated.ShortNames +import voltra.models.* + +/** + * Utility to expand shortened keys in the Voltra payload back to their full names. + * This should be run as the first step after JSON parsing. + */ +object VoltraDecompressor { + /** + * Decompress the entire payload recursively. + */ + fun decompress(payload: VoltraPayload): VoltraPayload { + return payload.copy( + collapsed = payload.collapsed?.let { decompressNode(it) }, + expanded = payload.expanded?.let { decompressNode(it) }, + variants = payload.variants?.mapValues { decompressNode(it.value) }, + s = payload.s?.map { decompressMap(it) }, + e = payload.e?.map { decompressNode(it) } + ) + } + + private fun decompressNode(node: VoltraNode): VoltraNode { + return when (node) { + is VoltraNode.Element -> VoltraNode.Element(decompressElement(node.element)) + is VoltraNode.Array -> VoltraNode.Array(node.elements.map { decompressNode(it) }) + else -> node // Text and Ref are primitive/don't have keys + } + } + + private fun decompressElement(element: VoltraElement): VoltraElement { + return element.copy( + p = element.p?.let { decompressMap(it) }, + c = element.c?.let { decompressNode(it) } + ) + } + + /** + * Recursively decompress a map of props or styles. + */ + @Suppress("UNCHECKED_CAST") + private fun decompressMap(map: Map): Map { + val result = mutableMapOf() + + for ((key, value) in map) { + val expandedKey = ShortNames.expand(key) + val expandedValue = when (value) { + is Map<*, *> -> decompressMap(value as Map) + is List<*> -> value.map { if (it is Map<*, *>) decompressMap(it as Map) else it } + else -> value + } + result[expandedKey] = expandedValue + } + + return result + } +} diff --git a/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt b/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt new file mode 100644 index 0000000..32801c8 --- /dev/null +++ b/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt @@ -0,0 +1,39 @@ +package voltra.parsing + +import com.google.gson.* +import voltra.models.* +import java.lang.reflect.Type + +class VoltraNodeDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext, + ): VoltraNode = + when { + // String → Text node + json.isJsonPrimitive && json.asJsonPrimitive.isString -> + VoltraNode.Text(json.asString) + + // Array → Array of nodes + json.isJsonArray -> { + val elements = + json.asJsonArray.map { + context.deserialize(it, VoltraNode::class.java) + } + VoltraNode.Array(elements) + } + + // Object with $r → Reference + json.isJsonObject && json.asJsonObject.has("\$r") -> + VoltraNode.Ref(json.asJsonObject.get("\$r").asInt) + + // Object with t → Element + json.isJsonObject && json.asJsonObject.has("t") -> { + val element = context.deserialize(json, VoltraElement::class.java) + VoltraNode.Element(element) + } + + else -> throw JsonParseException("Unknown VoltraNode format: $json") + } +} diff --git a/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt b/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt new file mode 100644 index 0000000..8f061c9 --- /dev/null +++ b/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt @@ -0,0 +1,32 @@ +package voltra.parsing + +import android.util.Log +import com.google.gson.GsonBuilder +import voltra.models.* + +object VoltraPayloadParser { + private const val TAG = "VoltraPayloadParser" + + private val gson = + GsonBuilder() + .registerTypeAdapter(VoltraNode::class.java, VoltraNodeDeserializer()) + .create() + + fun parse(jsonString: String): VoltraPayload { + Log.d(TAG, "Parsing payload, length=${jsonString.length}") + // Log first 500 chars to see the structure + Log.d(TAG, "Payload preview: ${jsonString.take(500)}") + + val rawResult = gson.fromJson(jsonString, VoltraPayload::class.java) + + Log.d(TAG, "Decompressing payload...") + val result = VoltraDecompressor.decompress(rawResult) + + Log.d( + TAG, + "Parsed and decompressed: collapsed=${result.collapsed != null}, expanded=${result.expanded != null}, variants=${result.variants?.keys}", + ) + + return result + } +} diff --git a/android/src/main/java/voltra/payload/ComponentTypeID.kt b/android/src/main/java/voltra/payload/ComponentTypeID.kt new file mode 100644 index 0000000..d6f20a4 --- /dev/null +++ b/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -0,0 +1,43 @@ +// +// ComponentTypeID.kt +// +// AUTO-GENERATED from data/components.json +// DO NOT EDIT MANUALLY - Changes will be overwritten +// Schema version: 1.0.0 + +package voltra.payload + +/** + * Component type IDs mapped from data/components.json + * IDs are assigned sequentially based on order in components.json (0-indexed) + */ +object ComponentTypeID { + /** + * Get component name from numeric ID + */ + fun getComponentName(id: Int): String? { + return when (id) { + 0 -> "AndroidFilledButton" + 1 -> "AndroidImage" + 2 -> "AndroidSwitch" + 3 -> "AndroidCheckBox" + 4 -> "AndroidRadioButton" + 5 -> "AndroidBox" + 6 -> "AndroidButton" + 7 -> "AndroidCircleIconButton" + 8 -> "AndroidCircularProgressIndicator" + 9 -> "AndroidColumn" + 10 -> "AndroidLazyColumn" + 11 -> "AndroidLazyVerticalGrid" + 12 -> "AndroidLinearProgressIndicator" + 13 -> "AndroidOutlineButton" + 14 -> "AndroidRow" + 15 -> "AndroidScaffold" + 16 -> "AndroidSpacer" + 17 -> "AndroidSquareIconButton" + 18 -> "AndroidText" + 19 -> "AndroidTitleBar" + else -> null + } + } +} diff --git a/android/src/main/java/voltra/styling/JSColorParser.kt b/android/src/main/java/voltra/styling/JSColorParser.kt new file mode 100644 index 0000000..342d91d --- /dev/null +++ b/android/src/main/java/voltra/styling/JSColorParser.kt @@ -0,0 +1,249 @@ +package voltra.styling + +import android.util.Log +import androidx.compose.ui.graphics.Color + +/** + * Parses JavaScript color values into Compose Color. + * Mirrors iOS JSColorParser.swift - supports hex, rgb, rgba, hsl, hsla, and named colors. + */ +object JSColorParser { + private const val TAG = "JSColorParser" + + /** + * Parse color value from any format. + * Supports: hex (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named colors. + */ + fun parse(value: Any?): Color? { + val string = value?.toString()?.trim()?.lowercase() ?: return null + + if (string.isEmpty()) return null + + // 1. Hex colors (with or without #) + if (string.startsWith("#")) { + return parseHex(string) + } + + // Check for hex without # prefix (6 or 8 hex digits) + if (isHexColor(string)) { + return parseHex("#$string") + } + + // 2. RGB/RGBA + if (string.startsWith("rgb")) { + return parseRGB(string) + } + + // 3. HSL/HSLA + if (string.startsWith("hsl")) { + return parseHSL(string) + } + + // 4. Named colors + return parseNamedColor(string) + } + + /** + * Check if string is valid hex color (6 or 8 hex digits). + */ + private fun isHexColor(string: String): Boolean { + if (string.length != 6 && string.length != 8) return false + return string.all { it in '0'..'9' || it in 'a'..'f' } + } + + /** + * Parse hex color: #RGB, #RGBA, #RRGGBB, #RRGGBBAA + */ + private fun parseHex(hex: String): Color? { + return try { + val hexSanitized = hex.removePrefix("#") + val length = hexSanitized.length + + val rgb: Long = hexSanitized.toLong(16) + + val (r, g, b, a) = + when (length) { + 3 -> { // #RGB + val red = ((rgb shr 8) and 0xF) / 15.0 + val green = ((rgb shr 4) and 0xF) / 15.0 + val blue = (rgb and 0xF) / 15.0 + listOf(red, green, blue, 1.0) + } + 4 -> { // #RGBA + val red = ((rgb shr 12) and 0xF) / 15.0 + val green = ((rgb shr 8) and 0xF) / 15.0 + val blue = ((rgb shr 4) and 0xF) / 15.0 + val alpha = (rgb and 0xF) / 15.0 + listOf(red, green, blue, alpha) + } + 6 -> { // #RRGGBB + val red = ((rgb shr 16) and 0xFF) / 255.0 + val green = ((rgb shr 8) and 0xFF) / 255.0 + val blue = (rgb and 0xFF) / 255.0 + listOf(red, green, blue, 1.0) + } + 8 -> { // #RRGGBBAA + val red = ((rgb shr 24) and 0xFF) / 255.0 + val green = ((rgb shr 16) and 0xFF) / 255.0 + val blue = ((rgb shr 8) and 0xFF) / 255.0 + val alpha = (rgb and 0xFF) / 255.0 + listOf(red, green, blue, alpha) + } + else -> return null + } + + Color( + red = r.toFloat(), + green = g.toFloat(), + blue = b.toFloat(), + alpha = a.toFloat(), + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse hex color: $hex", e) + null + } + } + + /** + * Parse RGB/RGBA: rgb(255, 0, 0) or rgba(255, 0, 0, 0.5) + */ + private fun parseRGB(string: String): Color? { + return try { + val cleaned = + string + .replace("rgba", "") + .replace("rgb", "") + .replace("(", "") + .replace(")", "") + .replace(" ", "") + + val components = cleaned.split(",") + if (components.size < 3) return null + + val r = components[0].toDouble() + val g = components[1].toDouble() + val b = components[2].toDouble() + val a = if (components.size >= 4) components[3].toDouble() else 1.0 + + Color( + red = (r / 255.0).toFloat(), + green = (g / 255.0).toFloat(), + blue = (b / 255.0).toFloat(), + alpha = a.toFloat(), + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse RGB color: $string", e) + null + } + } + + /** + * Parse HSL/HSLA: hsl(120, 100%, 50%) or hsla(...) + */ + private fun parseHSL(string: String): Color? { + return try { + val cleaned = + string + .replace("hsla", "") + .replace("hsl", "") + .replace("(", "") + .replace(")", "") + .replace(" ", "") + .replace("%", "") + + val components = cleaned.split(",") + if (components.size < 3) return null + + val h = (components[0].toDouble()) / 360.0 + val s = (components[1].toDouble()) / 100.0 + val l = (components[2].toDouble()) / 100.0 + val a = if (components.size >= 4) components[3].toDouble() else 1.0 + + val (r, g, b) = hslToRgb(h, s, l) + + Color( + red = r.toFloat(), + green = g.toFloat(), + blue = b.toFloat(), + alpha = a.toFloat(), + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse HSL color: $string", e) + null + } + } + + /** + * Convert HSL to RGB. + * @param h Hue (0.0 to 1.0) + * @param s Saturation (0.0 to 1.0) + * @param l Lightness (0.0 to 1.0) + * @return RGB triple with values from 0.0 to 1.0 + */ + private fun hslToRgb( + h: Double, + s: Double, + l: Double, + ): Triple { + // Achromatic case (no saturation) + if (s == 0.0) { + return Triple(l, l, l) + } + + val q = if (l < 0.5) l * (1 + s) else l + s - l * s + val p = 2 * l - q + + val r = hueToRgb(p, q, h + 1.0 / 3.0) + val g = hueToRgb(p, q, h) + val b = hueToRgb(p, q, h - 1.0 / 3.0) + + return Triple(r, g, b) + } + + /** + * Helper function for HSL to RGB conversion. + */ + private fun hueToRgb( + p: Double, + q: Double, + tInput: Double, + ): Double { + var t = tInput + if (t < 0) t += 1 + if (t > 1) t -= 1 + + return when { + t < 1.0 / 6.0 -> p + (q - p) * 6 * t + t < 1.0 / 2.0 -> q + t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6 + else -> p + } + } + + /** + * Parse named color strings. + * Supports common CSS/React Native color names. + */ + private fun parseNamedColor(name: String): Color? = + when (name) { + "red" -> Color.Red + "orange" -> Color(0xFFFFA500) + "yellow" -> Color.Yellow + "green" -> Color.Green + "mint" -> Color(0xFF00FF7F) + "teal" -> Color(0xFF008080) + "cyan" -> Color.Cyan + "blue" -> Color.Blue + "indigo" -> Color(0xFF4B0082) + "purple" -> Color(0xFF800080) + "pink" -> Color(0xFFFFC0CB) + "brown" -> Color(0xFFA52A2A) + "white" -> Color.White + "gray", "grey" -> Color.Gray + "black" -> Color.Black + "clear", "transparent" -> Color.Transparent + "lightgray", "lightgrey" -> Color.LightGray + "darkgray", "darkgrey" -> Color.DarkGray + else -> null + } +} diff --git a/android/src/main/java/voltra/styling/JSStyleParser.kt b/android/src/main/java/voltra/styling/JSStyleParser.kt new file mode 100644 index 0000000..94281a0 --- /dev/null +++ b/android/src/main/java/voltra/styling/JSStyleParser.kt @@ -0,0 +1,305 @@ +package voltra.styling + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.text.FontWeight + +/** + * Parses JavaScript style values into Kotlin types. + * Mirrors iOS JSStyleParser.swift - handles type conversions and fallbacks. + */ +object JSStyleParser { + private const val TAG = "JSStyleParser" + + /** + * Parse a number value (Int, Long, Float, Double) to Float. + * Returns null if value cannot be converted. + */ + fun number(value: Any?): Float? = + when (value) { + is Int -> value.toFloat() + is Long -> value.toFloat() + is Float -> value + is Double -> value.toFloat() + is String -> value.toFloatOrNull() + else -> null + } + + /** + * Parse a number value to Dp. + */ + fun dp(value: Any?): Dp? = number(value)?.dp + + /** + * Parse a number value to sp (for font sizes). + */ + fun sp(value: Any?): TextUnit? = number(value)?.sp + + /** + * Parse boolean values with fallback support. + */ + fun boolean(value: Any?): Boolean = + when (value) { + is Boolean -> value + is Int -> value == 1 + is String -> value.equals("true", ignoreCase = true) + else -> false + } + + /** + * Parse a size object {width: number, height: number}. + */ + fun size(value: Any?): Offset? { + if (value !is Map<*, *>) return null + val w = number(value["width"]) ?: 0f + val h = number(value["height"]) ?: 0f + return Offset(w.dp, h.dp) + } + + /** + * Parse color string (delegates to JSColorParser). + */ + fun color(value: Any?): androidx.compose.ui.graphics.Color? = JSColorParser.parse(value) + + /** + * Parse edge insets using short property names from JS payload. + * + * Short name mappings: + * - padding: pad, pt (top), pb (bottom), pl (left), pr (right), pv (vertical), ph (horizontal) + */ + fun parseInsets( + from: Map, + prefix: String, + ): EdgeInsets { + // Determine the expanded names based on prefix + val keys = + when (prefix) { + "padding" -> arrayOf("padding", "paddingTop", "paddingBottom", "paddingLeft", "paddingRight", "paddingVertical", "paddingHorizontal") + "margin" -> arrayOf("margin", "marginTop", "marginBottom", "marginLeft", "marginRight", "marginVertical", "marginHorizontal") + else -> + arrayOf( + prefix, + "${prefix}Top", + "${prefix}Bottom", + "${prefix}Left", + "${prefix}Right", + "${prefix}Vertical", + "${prefix}Horizontal", + ) + } + + val allKey = keys[0] + val topKey = keys[1] + val bottomKey = keys[2] + val leftKey = keys[3] + val rightKey = keys[4] + val verticalKey = keys[5] + val horizontalKey = keys[6] + + val all = number(from[allKey]) ?: 0f + val v = number(from[verticalKey]) ?: all + val h = number(from[horizontalKey]) ?: all + + val top = number(from[topKey]) ?: v + val bottom = number(from[bottomKey]) ?: v + val leading = number(from[leftKey]) ?: h + val trailing = number(from[rightKey]) ?: h + + return EdgeInsets( + top = top.dp, + leading = leading.dp, + bottom = bottom.dp, + trailing = trailing.dp, + ) + } + + /** + * Parse font weight from string or number. + * Maps "bold", "600", "normal" -> FontWeight + */ + fun fontWeight(value: Any?): FontWeight? { + val string = value?.toString()?.lowercase() ?: return null + + return when (string) { + "bold", "700" -> FontWeight.Bold + "medium", "500" -> FontWeight.Medium + "normal", "400", "regular" -> FontWeight.Normal + // Glance doesn't support these weights, but we map to closest + "semibold", "600" -> FontWeight.Bold + "light", "300" -> FontWeight.Normal + "thin", "100", "200" -> FontWeight.Normal + "heavy", "800", "900", "black" -> FontWeight.Bold + else -> null + } + } + + /** + * Parse text alignment from string. + * Maps "center", "right", "justify" -> TextAlignment + */ + fun textAlignment(value: Any?): TextAlignment { + val string = value?.toString()?.lowercase() ?: return TextAlignment.START + + return when (string) { + "center" -> TextAlignment.CENTER + "right", "end" -> TextAlignment.END + "left", "start" -> TextAlignment.START + else -> TextAlignment.START + } + } + + /** + * Parse text decoration from string. + * Supports "underline", "line-through", "strikethrough". + */ + fun textDecoration(value: Any?): TextDecoration { + val string = value?.toString()?.lowercase() ?: return TextDecoration.NONE + + val hasUnderline = string.contains("underline") + val hasLineThrough = string.contains("line-through") || string.contains("strikethrough") + + return when { + hasUnderline && hasLineThrough -> TextDecoration.UNDERLINE_LINE_THROUGH + hasUnderline -> TextDecoration.UNDERLINE + hasLineThrough -> TextDecoration.LINE_THROUGH + else -> TextDecoration.NONE + } + } + + /** + * Parse visibility from generic value. + */ + fun visibility(value: Any?): androidx.glance.Visibility? { + val string = value?.toString()?.lowercase() ?: return null + return when (string) { + "none" -> androidx.glance.Visibility.Gone + "hidden", "invisible" -> androidx.glance.Visibility.Invisible + "flex", "visible" -> androidx.glance.Visibility.Visible + else -> null + } + } + + /** + * Parse overflow behavior. + */ + fun overflow(value: Any?): Overflow? { + val string = value?.toString()?.lowercase() ?: return null + + return when (string) { + "hidden" -> Overflow.HIDDEN + "visible" -> Overflow.VISIBLE + else -> null + } + } + + /** + * Parse glass effect (iOS-specific, not supported in Glance). + */ + fun glassEffect(value: Any?): GlassEffect? { + val string = value?.toString()?.lowercase() ?: return null + + return when (string) { + "clear" -> GlassEffect.CLEAR + "identity" -> GlassEffect.IDENTITY + "regular" -> GlassEffect.REGULAR + "none" -> GlassEffect.NONE + else -> null + } + } + + /** + * Parse font variants from array or single string. + */ + fun fontVariant(value: Any?): Set { + val variants = mutableSetOf() + + when (value) { + is List<*> -> { + value.forEach { variantString -> + FontVariant + .values() + .find { + it.value == variantString.toString() + }?.let { variants.add(it) } + } + } + is String -> { + FontVariant + .values() + .find { + it.value == value + }?.let { variants.add(it) } + } + } + + return variants + } + + /** + * Parse RN transform array: [{ rotate: '45deg' }, { scale: 1.5 }] + * Returns null if no valid transforms found. + */ + fun transform(value: Any?): TransformStyle? { + if (value !is List<*>) return null + + var rotate: Float? = null + var scale: Float? = null + var scaleX: Float? = null + var scaleY: Float? = null + + value.forEach { item -> + if (item is Map<*, *>) { + // Handle rotate: '45deg' or rotate: '0.785rad' + item["rotate"]?.let { rotateStr -> + rotate = parseAngle(rotateStr.toString()) + } + item["rotateZ"]?.let { rotateStr -> + rotate = parseAngle(rotateStr.toString()) + } + // Handle scale + item["scale"]?.let { scaleValue -> + scale = number(scaleValue) + } + // Handle scaleX + item["scaleX"]?.let { scaleValue -> + scaleX = number(scaleValue) + } + // Handle scaleY + item["scaleY"]?.let { scaleValue -> + scaleY = number(scaleValue) + } + } + } + + // Only return if at least one transform is set + return if (rotate != null || scale != null || scaleX != null || scaleY != null) { + TransformStyle(rotate, scale, scaleX, scaleY) + } else { + null + } + } + + /** + * Parse angle string like '45deg' or '0.785rad' to degrees. + */ + private fun parseAngle(value: String): Float? { + val trimmed = value.trim() + + return when { + trimmed.endsWith("deg") -> { + trimmed.dropLast(3).toFloatOrNull() + } + trimmed.endsWith("rad") -> { + trimmed.dropLast(3).toFloatOrNull()?.let { + it * 180f / Math.PI.toFloat() + } + } + else -> { + // Try parsing as plain number (assume degrees) + trimmed.toFloatOrNull() + } + } + } +} diff --git a/android/src/main/java/voltra/styling/StyleConverter.kt b/android/src/main/java/voltra/styling/StyleConverter.kt new file mode 100644 index 0000000..c54edf1 --- /dev/null +++ b/android/src/main/java/voltra/styling/StyleConverter.kt @@ -0,0 +1,237 @@ +package voltra.styling + +import androidx.compose.ui.unit.dp + +/** + * Converts JavaScript style dictionary into structured style objects. + * Mirrors iOS StyleConverter.swift - parses JS values into typed Kotlin structures. + */ +object StyleConverter { + private const val TAG = "StyleConverter" + + /** + * Convert JavaScript style dictionary into structured styles. + * Returns CompositeStyle with all style categories parsed. + * + * Mirrors iOS: StyleConverter.convert(_ js: [String: Any]) + */ + fun convert(js: Map): CompositeStyle = + CompositeStyle( + layout = parseLayout(js), + decoration = parseDecoration(js), + rendering = parseRendering(js), + text = parseText(js), + ) + + /** + * Parse layout-related styles (dimensions, spacing, flex). + * Uses expanded property names after decompression (e.g., "width" for width, "flex" for flex). + */ + private fun parseLayout(js: Map): LayoutStyle { + // Flex logic: RN "flex: 1" implies grow. "flexGrow" is specific. + // In Glance, we use weight() modifier for proper flex behavior. + // Expanded names: flex, flexGrow + val flexVal = JSStyleParser.number(js["flex"]) ?: 0f + val flexGrow = JSStyleParser.number(js["flexGrow"]) ?: 0f + val finalWeight = maxOf(flexVal, flexGrow) + val weight = if (finalWeight > 0f) finalWeight else null + + // Position parsing (left/top -> Offset) + // Expanded names: left, top + var position: Offset? = null + val left = JSStyleParser.dp(js["left"]) + val top = JSStyleParser.dp(js["top"]) + if (left != null || top != null) { + position = Offset(left ?: 0.dp, top ?: 0.dp) + } + + // zIndex: only set if explicitly provided in JS + // Expanded name: zIndex + val zIndex = JSStyleParser.number(js["zIndex"]) + + return LayoutStyle( + // Dimensions (width, height, minWidth/maxWidth/minHeight/maxHeight) + // Support number (dp), "100%" (fill), "auto" (wrap), or null (wrap) + width = parseSizeValue(js["width"]), + height = parseSizeValue(js["height"]), + minWidth = JSStyleParser.dp(js["minWidth"]), + maxWidth = JSStyleParser.dp(js["maxWidth"]), + minHeight = JSStyleParser.dp(js["minHeight"]), + maxHeight = JSStyleParser.dp(js["maxHeight"]), + // Flex Logic (aspectRatio) + weight = weight, + aspectRatio = JSStyleParser.number(js["aspectRatio"]), + // Spacing (padding) + padding = JSStyleParser.parseInsets(js, "padding"), + // Positioning + position = position, + zIndex = zIndex, + // Visibility logic: + // display: 'none' -> Gone (takes no space) + // visibility: 'hidden' -> Invisible (hides but takes space) + visibility = + run { + val d = JSStyleParser.visibility(js["display"]) + val v = JSStyleParser.visibility(js["visibility"]) + + when { + d == androidx.glance.Visibility.Gone -> androidx.glance.Visibility.Gone + v == androidx.glance.Visibility.Invisible -> androidx.glance.Visibility.Invisible + else -> d ?: v + } + }, + ) + } + + /** + * Parse size value from JS - can be number (dp), "100%" (fill), or null/auto (wrap). + */ + private fun parseSizeValue(value: Any?): SizeValue? = + when (value) { + is Number -> SizeValue.Fixed(value.toFloat().dp) + "100%" -> SizeValue.Fill + "auto" -> SizeValue.Wrap + null -> SizeValue.Wrap + else -> { + // Try to parse as string percentage or number + val str = value.toString() + when { + str == "100%" -> SizeValue.Fill + str == "auto" -> SizeValue.Wrap + else -> { + // Try to parse as number + str.toFloatOrNull()?.let { SizeValue.Fixed(it.dp) } ?: SizeValue.Wrap + } + } + } + } + + /** + * Parse decoration-related styles (background, border, shadow). + * Uses expanded property names after decompression. + */ + private fun parseDecoration(js: Map): DecorationStyle { + // Border Logic (borderWidth, borderColor) + var border: BorderStyle? = null + val borderWidth = JSStyleParser.dp(js["borderWidth"]) + if (borderWidth != null && borderWidth.value > 0) { + val borderColor = + JSStyleParser.color(js["borderColor"]) + ?: androidx.compose.ui.graphics.Color.Transparent + border = BorderStyle(borderWidth, borderColor) + } + + // Shadow Logic (RN iOS style) - not supported in Glance + // Expanded names: shadowRadius, shadowOpacity, shadowOffset, shadowColor + var shadow: ShadowStyle? = null + val shadowRadius = JSStyleParser.dp(js["shadowRadius"]) + val shadowOpacity = JSStyleParser.number(js["shadowOpacity"]) + val shadowOffset = JSStyleParser.size(js["shadowOffset"]) + val shadowColor = JSStyleParser.color(js["shadowColor"]) + + if (shadowRadius != null || shadowOpacity != null || shadowOffset != null || shadowColor != null) { + val finalRadius = shadowRadius ?: 0.dp + val finalOpacity = shadowOpacity ?: 1.0f + val finalOffset = shadowOffset ?: Offset(0.dp, 0.dp) + val finalColor = shadowColor ?: androidx.compose.ui.graphics.Color.Black + + if (finalOpacity > 0) { + shadow = ShadowStyle(finalRadius, finalColor, finalOpacity, finalOffset) + } + } + + // Expanded names: glassEffect, overflow, backgroundColor, borderRadius + val glassEffect = JSStyleParser.glassEffect(js["glassEffect"]) + val overflow = JSStyleParser.overflow(js["overflow"]) + val clipToOutline = overflow == Overflow.HIDDEN + + return DecorationStyle( + backgroundColor = JSStyleParser.color(js["backgroundColor"]), + cornerRadius = JSStyleParser.dp(js["borderRadius"]), + clipToOutline = clipToOutline, + border = border, + shadow = shadow, + glassEffect = glassEffect, + overflow = overflow, + ) + } + + /** + * Parse rendering-related styles (opacity, transform). + * Uses expanded property names: opacity, transform + */ + private fun parseRendering(js: Map): RenderingStyle { + val opacity = JSStyleParser.number(js["opacity"]) ?: 1.0f + return RenderingStyle( + opacity = opacity, + transform = JSStyleParser.transform(js["transform"]), + ) + } + + /** + * Parse text-related styles. + * Uses expanded property names after decompression. + */ + private fun parseText(js: Map): TextStyle { + var style = TextStyle.Default + + // Color + val color = JSStyleParser.color(js["color"]) + if (color != null) { + style = style.copy(color = color) + } + + // Font size + val fontSize = JSStyleParser.sp(js["fontSize"]) + if (fontSize != null) { + style = style.copy(fontSize = fontSize) + } + + // Line height: CSS lineHeight includes text size. We calculate extra spacing. + // Note: Glance has limited line spacing support + val lineHeight = JSStyleParser.number(js["lineHeight"]) + if (lineHeight != null) { + val currentFontSize = fontSize?.value ?: 17f + val spacing = lineHeight - currentFontSize + style = style.copy(lineSpacing = maxOf(0f, spacing).dp) + } + + // Font weight + val fontWeight = JSStyleParser.fontWeight(js["fontWeight"]) + if (fontWeight != null) { + style = style.copy(fontWeight = fontWeight) + } + + // Text alignment + val textAlign = js["textAlign"] + if (textAlign != null) { + style = style.copy(alignment = JSStyleParser.textAlignment(textAlign)) + } + + // Text decoration + val decoration = js["textDecorationLine"] + if (decoration != null) { + style = style.copy(decoration = JSStyleParser.textDecoration(decoration)) + } + + // Number of lines + val numberOfLines = (js["numberOfLines"] as? Number)?.toInt() + if (numberOfLines != null) { + style = style.copy(lineLimit = numberOfLines) + } + + // Letter spacing (not supported in Glance) + val letterSpacing = JSStyleParser.dp(js["letterSpacing"]) + if (letterSpacing != null) { + style = style.copy(letterSpacing = letterSpacing) + } + + // Font variant (not supported in Glance) + val fontVariant = js["fontVariant"] + if (fontVariant != null) { + style = style.copy(fontVariant = JSStyleParser.fontVariant(fontVariant)) + } + + return style + } +} diff --git a/android/src/main/java/voltra/styling/StyleModifiers.kt b/android/src/main/java/voltra/styling/StyleModifiers.kt new file mode 100644 index 0000000..3223971 --- /dev/null +++ b/android/src/main/java/voltra/styling/StyleModifiers.kt @@ -0,0 +1,303 @@ +package voltra.styling + +import android.os.Build +import android.util.Log +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.* +import androidx.glance.unit.ColorProvider +import androidx.glance.visibility +import androidx.glance.text.TextDecoration as GlanceTextDecoration +import androidx.glance.text.TextStyle as GlanceTextStyle + +/** + * Extension functions to apply structured styles to Glance modifiers. + * Mirrors iOS View+applyStyle.swift - provides clean interface for style application. + * + * Apply composite style to a GlanceModifier. + * This is the main entry point for applying styles. + * + * Order of application (mirrors iOS): + * 1. Layout (dimensions, flex, padding) + * 2. Decoration (background, border) + * 3. Rendering (opacity - limited support) + * + * Usage: + * GlanceModifier.applyStyle(style) + */ +fun GlanceModifier.applyStyle(style: CompositeStyle): GlanceModifier { + var modifier = this + + // 1. Apply Layout (dimensions, flex, inner padding) + modifier = modifier.applyLayout(style.layout) + + // 2. Apply Decoration (background, border) + modifier = modifier.applyDecoration(style.decoration) + + // 3. Apply Rendering (opacity - limited in Glance) + modifier = modifier.applyRendering(style.rendering) + + return modifier +} + +/** + * Extension for ROW scope to apply flex/weight modifier. + * .defaultWeight() and .weight() are only available in RowScope. + */ +fun RowScope.applyFlex( + modifier: GlanceModifier, + flex: Float?, +): GlanceModifier = + if (flex != null && flex > 0) { + // .defaultWeight() is available here because we are in RowScope + modifier.defaultWeight() + } else { + modifier + } + +/** + * Extension for COLUMN scope to apply flex/weight modifier. + * .defaultWeight() and .weight() are only available in ColumnScope. + */ +fun ColumnScope.applyFlex( + modifier: GlanceModifier, + flex: Float?, +): GlanceModifier = + if (flex != null && flex > 0) { + // .defaultWeight() is available here because we are in ColumnScope + modifier.defaultWeight() + } else { + modifier + } + +/** + * Apply layout styles to modifier. + * Handles dimensions and inner padding. + * Note: Weight/Flex is handled separately in RowScope/ColumnScope via applyFlex(). + * + * Order of application: + * 1. FillMaxSize optimization (if both width and height are Fill) + * 2. Width (Fill, Fixed, or Wrap) + * 3. Height (Fill, Fixed, or Wrap) + * 4. Padding + */ +private fun GlanceModifier.applyLayout(layout: LayoutStyle): GlanceModifier { + var modifier = this + + // --- PHASE 1: Fill Max Size (Optimization) --- + // Check if BOTH are set to Fill (100%) to use the specialized fillMaxSize modifier + val isFullWidth = layout.width is SizeValue.Fill + val isFullHeight = layout.height is SizeValue.Fill + + if (isFullWidth && isFullHeight) { + // Optimization: Use one modifier instead of two + modifier = modifier.fillMaxSize() + } else { + // --- PHASE 3: Width (Fill vs Wrap vs Fixed) --- + modifier = + when (val width = layout.width) { + is SizeValue.Fill -> modifier.fillMaxWidth() + is SizeValue.Fixed -> modifier.width(width.value) + is SizeValue.Wrap -> modifier.wrapContentWidth() + null -> modifier.wrapContentWidth() + } + + // --- PHASE 4: Height (Fill vs Wrap vs Fixed) --- + modifier = + when (val height = layout.height) { + is SizeValue.Fill -> modifier.fillMaxHeight() + is SizeValue.Fixed -> modifier.height(height.value) + is SizeValue.Wrap -> modifier.wrapContentHeight() + null -> modifier.wrapContentHeight() + } + } + + // --- PHASE 5: Min/Max constraints (not supported in Glance - log warning) --- + if (layout.minWidth != null || + layout.maxWidth != null || + layout.minHeight != null || + layout.maxHeight != null + ) { + Log.w( + "StyleModifier", + "Min/max width/height constraints not supported in Glance widgets", + ) + } + + // --- PHASE 6: Aspect ratio (not supported in Glance) --- + if (layout.aspectRatio != null) { + Log.w("StyleModifier", "aspectRatio not supported in Glance widgets") + } + + // --- PHASE 7: Inner padding (applied after sizing) --- + val padding = layout.padding + if (padding != null && !padding.isZero()) { + modifier = + modifier.padding( + start = padding.leading, + top = padding.top, + end = padding.trailing, + bottom = padding.bottom, + ) + } + + // --- PHASE 8: Visibility --- + if (layout.visibility != null) { + modifier = modifier.visibility(layout.visibility) + } + + return modifier +} + +/** + * Apply decoration styles to modifier. + * Handles background color, corner radius, and borders. + */ +private fun GlanceModifier.applyDecoration(decoration: DecorationStyle): GlanceModifier { + var modifier = this + + // A. Background color + if (decoration.backgroundColor != null) { + modifier = modifier.background(decoration.backgroundColor) + } + + // B. Corner radius (requires Android 12+/API 31+) + if (decoration.cornerRadius != null && decoration.cornerRadius.value > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + modifier = modifier.cornerRadius(decoration.cornerRadius) + } else { + Log.w( + "StyleModifier", + "cornerRadius requires Android 12+ (API 31+), current: ${Build.VERSION.SDK_INT}", + ) + } + } + + // C. Border (not yet implemented in Glance) + if (decoration.border != null) { + Log.w("StyleModifier", "Border styling not yet implemented for Glance widgets") + // TODO: Implement using GlanceModifier.border() when available + } + + // D. Shadow (not supported in Glance) + if (decoration.shadow != null) { + Log.w("StyleModifier", "Shadow effects not supported in Glance widgets") + } + + // E. Overflow (not supported in Glance) + if (decoration.overflow != null) { + Log.w("StyleModifier", "Overflow control not supported in Glance widgets") + } + + // F. Glass effect (iOS-specific, not supported) + if (decoration.glassEffect != null) { + Log.w("StyleModifier", "Glass effects not supported in Glance widgets") + } + + return modifier +} + +/** + * Apply rendering styles to modifier. + * Handles opacity and transforms (limited support in Glance). + */ +private fun GlanceModifier.applyRendering(rendering: RenderingStyle): GlanceModifier { + var modifier = this + + // A. Opacity (Glance doesn't have opacity modifier) + // Would need to apply alpha to all colors instead + if (rendering.opacity < 1.0f) { + Log.w( + "StyleModifier", + "Opacity modifier not supported in Glance - apply alpha to colors instead", + ) + } + + // B. Transform (not supported in Glance) + if (rendering.transform != null) { + Log.w("StyleModifier", "Transform effects not supported in Glance widgets") + } + + return modifier +} + +/** + * Convert TextStyle to GlanceTextStyle. + * Glance has limited text styling compared to SwiftUI. + */ +fun TextStyle.toGlanceTextStyle(): GlanceTextStyle { + var glanceStyle = GlanceTextStyle() + + // Font size + if (fontSize.value > 0) { + glanceStyle = GlanceTextStyle(fontSize = fontSize) + } + + // Color + if (color != null) { + glanceStyle = + GlanceTextStyle( + fontSize = fontSize, + color = ColorProvider(color), + ) + } + + // Font weight + if (fontWeight != null) { + glanceStyle = + GlanceTextStyle( + fontSize = fontSize, + color = + color?.let { ColorProvider(it) } ?: ColorProvider(androidx.compose.ui.graphics.Color.Unspecified), + fontWeight = fontWeight, + ) + } + + // Text decoration (limited support) + val glanceDecoration = + when (decoration) { + TextDecoration.UNDERLINE -> GlanceTextDecoration.Underline + TextDecoration.LINE_THROUGH -> GlanceTextDecoration.LineThrough + TextDecoration.UNDERLINE_LINE_THROUGH -> { + Log.w("StyleModifier", "Combined underline + line-through not supported, using underline") + GlanceTextDecoration.Underline + } + TextDecoration.NONE -> null + } + + if (glanceDecoration != null) { + glanceStyle = + GlanceTextStyle( + fontSize = fontSize, + color = + color?.let { ColorProvider(it) } ?: ColorProvider(androidx.compose.ui.graphics.Color.Unspecified), + fontWeight = fontWeight, + textDecoration = glanceDecoration, + ) + } + + // Text alignment - handled at Text component level, not in TextStyle + // Line limit - handled at Text component level + // Letter spacing - not supported in Glance + // Font variant - not supported in Glance + + if (letterSpacing.value > 0) { + Log.w("StyleModifier", "letterSpacing not supported in Glance TextStyle") + } + + if (fontVariant.isNotEmpty()) { + Log.w("StyleModifier", "fontVariant not supported in Glance TextStyle") + } + + return glanceStyle +} + +/** + * Check if EdgeInsets are all zero. + */ +private fun EdgeInsets.isZero(): Boolean = + top.value == 0f && + leading.value == 0f && + bottom.value == 0f && + trailing.value == 0f diff --git a/android/src/main/java/voltra/styling/StyleStructures.kt b/android/src/main/java/voltra/styling/StyleStructures.kt new file mode 100644 index 0000000..f774163 --- /dev/null +++ b/android/src/main/java/voltra/styling/StyleStructures.kt @@ -0,0 +1,207 @@ +package voltra.styling + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.Visibility +import androidx.glance.text.FontWeight + +/** + * Size value that can be a fixed dimension, percentage, or wrap content. + */ +sealed class SizeValue { + data class Fixed( + val value: Dp, + ) : SizeValue() + + object Fill : SizeValue() // "100%" + + object Wrap : SizeValue() // "auto" or undefined +} + +/** + * Layout-related styles (dimensions, spacing, flex). + * Mirrors iOS LayoutStyle.swift + */ +data class LayoutStyle( + // Dimensions - can be fixed dp, "100%" (fill), or wrap + val width: SizeValue? = null, + val height: SizeValue? = null, + val minWidth: Dp? = null, + val maxWidth: Dp? = null, + val minHeight: Dp? = null, + val maxHeight: Dp? = null, + // Flexibility - weight is the proper way to handle flex in Glance + val weight: Float? = null, + // Aspect Ratio (not supported in Glance, but kept for API compatibility) + val aspectRatio: Float? = null, + // Spacing + val padding: EdgeInsets? = null, + // Positioning (not supported in Glance, but kept for API compatibility) + val position: Offset? = null, + val zIndex: Float? = null, + // Visibility + val visibility: Visibility? = null, +) { + companion object { + val Default = LayoutStyle() + } +} + +/** + * Decoration styles (background, border, shadow, effects). + * Mirrors iOS DecorationStyle.swift + */ +data class DecorationStyle( + val backgroundColor: Color? = null, + val cornerRadius: Dp? = null, + val clipToOutline: Boolean = false, + val border: BorderStyle? = null, + val shadow: ShadowStyle? = null, // Not supported in Glance + val overflow: Overflow? = null, // Not supported in Glance + val glassEffect: GlassEffect? = null, // Not supported in Glance +) { + companion object { + val Default = DecorationStyle() + } +} + +/** + * Border configuration. + */ +data class BorderStyle( + val width: Dp, + val color: Color, +) + +/** + * Shadow configuration (not supported in Glance, but kept for API compatibility). + */ +data class ShadowStyle( + val radius: Dp, + val color: Color, + val opacity: Float, + val offset: Offset, +) + +/** + * Rendering styles (opacity, transform). + * Mirrors iOS RenderingStyle.swift + */ +data class RenderingStyle( + val opacity: Float = 1.0f, + val transform: TransformStyle? = null, // Not supported in Glance +) { + companion object { + val Default = RenderingStyle() + } +} + +/** + * Transform configuration (not supported in Glance, but kept for API compatibility). + */ +data class TransformStyle( + val rotate: Float? = null, // in degrees + val scale: Float? = null, + val scaleX: Float? = null, + val scaleY: Float? = null, +) + +/** + * Text-related styles. + * Mirrors iOS TextStyle.swift + */ +data class TextStyle( + val color: Color? = null, + val fontSize: TextUnit = 17.sp, + val fontWeight: FontWeight? = null, + val alignment: TextAlignment = TextAlignment.START, + val lineLimit: Int? = null, + val lineSpacing: Dp = 0.dp, // Not fully supported in Glance + val decoration: TextDecoration = TextDecoration.NONE, + val letterSpacing: Dp = 0.dp, // Not supported in Glance + val fontVariant: Set = emptySet(), // Not supported in Glance +) { + companion object { + val Default = TextStyle() + } +} + +/** + * Edge insets for padding. + * Mirrors SwiftUI EdgeInsets. + */ +data class EdgeInsets( + val top: Dp = 0.dp, + val leading: Dp = 0.dp, + val bottom: Dp = 0.dp, + val trailing: Dp = 0.dp, +) + +/** + * 2D offset for positioning. + */ +data class Offset( + val x: Dp = 0.dp, + val y: Dp = 0.dp, +) + +/** + * Text alignment options. + */ +enum class TextAlignment { + START, + CENTER, + END, +} + +/** + * Text decoration options. + */ +enum class TextDecoration { + NONE, + UNDERLINE, + LINE_THROUGH, + UNDERLINE_LINE_THROUGH, +} + +/** + * Overflow behavior (not supported in Glance). + */ +enum class Overflow { + VISIBLE, + HIDDEN, +} + +/** + * Glass effect options (not supported in Glance). + */ +enum class GlassEffect { + CLEAR, + IDENTITY, + REGULAR, + NONE, +} + +/** + * Font variant options (not supported in Glance). + */ +enum class FontVariant( + val value: String, +) { + SMALL_CAPS("small-caps"), + TABULAR_NUMS("tabular-nums"), +} + +/** + * Composite style containing all style categories. + * Mirrors iOS approach of returning a tuple. + */ +data class CompositeStyle( + val layout: LayoutStyle = LayoutStyle.Default, + val decoration: DecorationStyle = DecorationStyle.Default, + val rendering: RenderingStyle = RenderingStyle.Default, + val text: TextStyle = TextStyle.Default, +) diff --git a/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt b/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt new file mode 100644 index 0000000..8f783be --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraGlanceWidget.kt @@ -0,0 +1,171 @@ +package voltra.widget + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import voltra.glance.GlanceFactory +import voltra.models.VoltraPayload +import voltra.parsing.VoltraPayloadParser + +class VoltraGlanceWidget( + private val widgetId: String = "default", +) : GlanceAppWidget() { + companion object { + private const val TAG = "VoltraGlanceWidget" + + // Define size breakpoints for responsive widget rendering + private val SMALL = DpSize(150.dp, 100.dp) + private val MEDIUM_SQUARE = DpSize(200.dp, 200.dp) + private val MEDIUM_WIDE = DpSize(250.dp, 150.dp) + private val MEDIUM_TALL = DpSize(150.dp, 250.dp) + private val LARGE = DpSize(300.dp, 200.dp) + private val EXTRA_LARGE = DpSize(350.dp, 300.dp) + } + + // Use responsive sizing to support multiple widget dimensions + override val sizeMode = + SizeMode.Responsive( + setOf(SMALL, MEDIUM_SQUARE, MEDIUM_WIDE, MEDIUM_TALL, LARGE, EXTRA_LARGE), + ) + + override suspend fun provideGlance( + context: Context, + id: GlanceId, + ) { + // Parse data outside of composition to avoid try/catch in composable + val widgetManager = VoltraWidgetManager(context) + val jsonString = widgetManager.readWidgetJson(widgetId) + + val payload: VoltraPayload? = + if (jsonString != null) { + try { + VoltraPayloadParser.parse(jsonString) + } catch (e: Exception) { + Log.e(TAG, "Error parsing widget payload for widgetId=$widgetId: ${e.message}", e) + null + } + } else { + Log.d(TAG, "No JSON data found for widgetId=$widgetId") + null + } + + provideContent { + Content(payload) + } + } + + @Composable + private fun Content(payload: VoltraPayload?) { + val currentSize = LocalSize.current + + Log.d(TAG, "Content: widgetId=$widgetId, currentSize=${currentSize.width}x${currentSize.height}") + + if (payload == null) { + Log.d(TAG, "Content: payload is null, showing placeholder") + PlaceholderView() + return + } + + Log.d( + TAG, + "Content: variants keys=${payload.variants?.keys}, styles=${payload.s?.size ?: 0}, elements=${payload.e?.size ?: 0}", + ) + + // Select the best variant for current size + val variantKey = selectVariantForSize(currentSize, payload.variants?.keys) + val node = + if (variantKey != null && payload.variants != null) { + payload.variants[variantKey] + } else { + null + } + + if (node != null) { + Log.d(TAG, "Rendering widget widgetId=$widgetId with size variant: $variantKey") + GlanceFactory(widgetId, payload.e, payload.s).Render(node) + } else { + Log.d(TAG, "Content: no matching variant found, showing placeholder") + PlaceholderView() + } + } + + @Composable + private fun PlaceholderView() { + Box( + modifier = GlanceModifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text("Widget not configured") + } + } + + /** + * Select the best variant key based on current widget size. + * Matches against size keys in format "WIDTHxHEIGHT" (e.g., "150x100"). + */ + private fun selectVariantForSize( + currentSize: DpSize, + availableKeys: Set?, + ): String? { + if (availableKeys == null || availableKeys.isEmpty()) { + return null + } + + val currentWidthDp = currentSize.width.value + val currentHeightDp = currentSize.height.value + + // Parse available size keys into dimensions + data class SizeVariant( + val key: String, + val width: Float, + val height: Float, + ) + + val variants = + availableKeys.mapNotNull { key: String -> + val parts = key.split("x") + if (parts.size == 2) { + val width = parts[0].toFloatOrNull() + val height = parts[1].toFloatOrNull() + if (width != null && height != null) { + SizeVariant(key, width, height) + } else { + null + } + } else { + null + } + } + + if (variants.isEmpty()) { + return null + } + + // Find the closest match using Euclidean distance + val bestMatch = + variants.minByOrNull { variant: SizeVariant -> + val widthDiff = variant.width - currentWidthDp + val heightDiff = variant.height - currentHeightDp + kotlin.math.sqrt(widthDiff * widthDiff + heightDiff * heightDiff) + } + + Log.d( + TAG, + "Selected variant '${bestMatch?.key}' for size ${currentWidthDp}x$currentHeightDp (widgetId=$widgetId)", + ) + return bestMatch?.key + } +} diff --git a/android/src/main/java/voltra/widget/VoltraWidgetManager.kt b/android/src/main/java/voltra/widget/VoltraWidgetManager.kt new file mode 100644 index 0000000..6379ee5 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetManager.kt @@ -0,0 +1,314 @@ +package voltra.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import android.widget.RemoteViews +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceAppWidgetManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import voltra.glance.RemoteViewsGenerator +import voltra.parsing.VoltraPayloadParser +import java.io.InputStream +import java.nio.charset.Charset + +class VoltraWidgetManager( + private val context: Context, +) { + companion object { + private const val TAG = "VoltraWidgetManager" + private const val PREFS_NAME = "voltra_widgets" + private const val KEY_JSON_PREFIX = "Voltra_Widget_JSON_" + private const val KEY_DEEP_LINK_PREFIX = "Voltra_Widget_DeepLinkURL_" + private const val ASSET_INITIAL_STATES = "voltra_initial_states.json" + } + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * Write widget data to SharedPreferences + * Uses commit() instead of apply() to ensure data is written before widget update + */ + fun writeWidgetData( + widgetId: String, + jsonString: String, + deepLinkUrl: String?, + ) { + Log.d(TAG, "writeWidgetData: widgetId=$widgetId, deepLinkUrl=$deepLinkUrl") + Log.d(TAG, "JSON length: ${jsonString.length}, preview: ${jsonString.take(200)}") + + val editor = prefs.edit() + editor.putString("$KEY_JSON_PREFIX$widgetId", jsonString) + + if (deepLinkUrl != null && deepLinkUrl.isNotEmpty()) { + editor.putString("$KEY_DEEP_LINK_PREFIX$widgetId", deepLinkUrl) + } else { + editor.remove("$KEY_DEEP_LINK_PREFIX$widgetId") + } + + // Use commit() for synchronous write - ensures data is available before widget update + val success = editor.commit() + Log.d(TAG, "Widget data written. Success: $success, length: ${jsonString.length}") + } + + /** + * Read widget JSON from SharedPreferences. + * Falls back to pre-rendered initial state from assets if no dynamic data is found. + */ + fun readWidgetJson(widgetId: String): String? { + val json = prefs.getString("$KEY_JSON_PREFIX$widgetId", null) + if (json != null) { + Log.d(TAG, "readWidgetJson: widgetId=$widgetId, found in SharedPreferences, length=${json.length}") + return json + } + + // Fallback to pre-rendered state from assets + val preloadedJson = readPreloadedWidgetJson(widgetId) + if (preloadedJson != null) { + Log.d(TAG, "readWidgetJson: widgetId=$widgetId, found in assets, length=${preloadedJson.length}") + return preloadedJson + } + + Log.d(TAG, "readWidgetJson: widgetId=$widgetId, not found anywhere") + return null + } + + /** + * Read pre-rendered widget JSON from assets + */ + private fun readPreloadedWidgetJson(widgetId: String): String? = + try { + val inputStream: InputStream = context.assets.open(ASSET_INITIAL_STATES) + val size: Int = inputStream.available() + val buffer = ByteArray(size) + inputStream.read(buffer) + inputStream.close() + + val jsonString = String(buffer, Charset.forName("UTF-8")) + val jsonObject = JSONObject(jsonString) + + if (jsonObject.has(widgetId)) { + jsonObject.get(widgetId).toString() + } else { + null + } + } catch (e: Exception) { + // Asset might not exist or be invalid, which is fine if no pre-rendering was configured + null + } + + /** + * Read widget deep link URL from SharedPreferences + */ + fun readDeepLinkUrl(widgetId: String): String? = prefs.getString("$KEY_DEEP_LINK_PREFIX$widgetId", null) + + /** + * Clear widget data from SharedPreferences + */ + fun clearWidgetData(widgetId: String) { + Log.d(TAG, "clearWidgetData: widgetId=$widgetId") + + val editor = prefs.edit() + editor.remove("$KEY_JSON_PREFIX$widgetId") + editor.remove("$KEY_DEEP_LINK_PREFIX$widgetId") + editor.commit() + } + + /** + * Clear all widget data from SharedPreferences + */ + fun clearAllWidgetData() { + Log.d(TAG, "clearAllWidgetData") + + val allKeys = prefs.all.keys + val widgetKeys = + allKeys.filter { key: String -> + key.startsWith(KEY_JSON_PREFIX) || key.startsWith(KEY_DEEP_LINK_PREFIX) + } + + val editor = prefs.edit() + widgetKeys.forEach { key: String -> editor.remove(key) } + editor.commit() + + Log.d(TAG, "Cleared ${widgetKeys.size} widget keys") + } + + /** + * Update a widget directly using GlanceRemoteViews, bypassing Glance's session lock. + * This allows rapid widget updates without the 45-50 second cooldown. + * Uses RemoteViews with size mapping for responsive layouts (Android 12+ required). + */ + @OptIn(ExperimentalGlanceRemoteViewsApi::class) + suspend fun updateWidgetDirect(widgetId: String) = + withContext(Dispatchers.IO) { + Log.d(TAG, "updateWidgetDirect: widgetId=$widgetId") + + // 1. Read and parse the JSON payload + val jsonString = readWidgetJson(widgetId) + if (jsonString == null) { + Log.w(TAG, "No JSON data found for widgetId=$widgetId") + return@withContext + } + + val payload = + try { + VoltraPayloadParser.parse(jsonString) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse widget payload: ${e.message}", e) + return@withContext + } + + if (payload.variants.isNullOrEmpty()) { + Log.w(TAG, "No variants in payload for widgetId=$widgetId") + return@withContext + } + + // 2. Get widget instances from AppWidgetManager + val receiverClassName = "${context.packageName}.widget.VoltraWidget_${widgetId}Receiver" + val componentName = ComponentName(context.packageName, receiverClassName) + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + Log.d(TAG, "Found ${appWidgetIds.size} app widget instances for $widgetId") + + if (appWidgetIds.isEmpty()) { + Log.w(TAG, "No widget instances found on home screen for $widgetId") + return@withContext + } + + // 3. Generate RemoteViews for all variants + val sizeMapping = RemoteViewsGenerator.generateWidgetRemoteViews(context, payload) + + if (sizeMapping.isEmpty()) { + Log.e(TAG, "Failed to generate any RemoteViews for widgetId=$widgetId") + return@withContext + } + + // 4. Update each widget instance with responsive RemoteViews + for (appWidgetId in appWidgetIds) { + try { + // Android 12+ (API 31): Use RemoteViews with size mapping for responsive layout + // The system will automatically select the appropriate RemoteViews based on current size + val responsiveRemoteViews = RemoteViews(sizeMapping) + appWidgetManager.updateAppWidget(appWidgetId, responsiveRemoteViews) + Log.d(TAG, "Updated widget $appWidgetId with responsive RemoteViews (${sizeMapping.size} sizes)") + } catch (e: Exception) { + Log.e(TAG, "Failed to update widget instance $appWidgetId: ${e.message}", e) + } + } + + Log.d(TAG, "Direct widget update completed for $widgetId") + } + + /** + * Update a specific widget using direct RemoteViews generation. + * This bypasses Glance's session management to avoid the 45-50 second lock. + * + * Falls back to Glance's native update if direct update fails. + */ + suspend fun updateWidget(widgetId: String) = + withContext(Dispatchers.IO) { + Log.d(TAG, "updateWidget: widgetId=$widgetId") + + try { + // Try direct update first (bypasses session lock) + updateWidgetDirect(widgetId) + } catch (e: Exception) { + Log.w(TAG, "Direct widget update failed, falling back to Glance update: ${e.message}") + // Fallback to Glance's native update mechanism + updateWidgetViaGlance(widgetId) + } + } + + /** + * Update widget using Glance's native mechanism (has session lock). + * Kept as fallback for edge cases. + */ + private suspend fun updateWidgetViaGlance(widgetId: String) { + Log.d(TAG, "updateWidgetViaGlance: widgetId=$widgetId") + + // Build the receiver component name by convention (no reflection needed) + val receiverClassName = "${context.packageName}.widget.VoltraWidget_${widgetId}Receiver" + val componentName = ComponentName(context.packageName, receiverClassName) + Log.d(TAG, "Looking for receiver: $receiverClassName") + + // Get widget IDs using standard Android AppWidgetManager + val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) + + Log.d(TAG, "Found ${appWidgetIds.size} app widget instances for $widgetId: ${appWidgetIds.toList()}") + + if (appWidgetIds.isNotEmpty()) { + // Create the widget instance with the specific widgetId + val widget = VoltraGlanceWidget(widgetId) + + // Get the GlanceAppWidgetManager to convert IDs + val glanceManager = GlanceAppWidgetManager(context) + + // Update each widget instance using Glance's update mechanism + for (appWidgetId in appWidgetIds) { + try { + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + Log.d(TAG, "Updating Glance widget instance: appWidgetId=$appWidgetId, glanceId=$glanceId") + widget.update(context, glanceId) + } catch (e: Exception) { + Log.e(TAG, "Failed to update widget instance $appWidgetId: ${e.message}", e) + } + } + Log.d(TAG, "Glance widget update completed for $widgetId") + } else { + Log.w(TAG, "No widget instances found on home screen for $widgetId") + } + } + + /** + * Reload specific widgets or all widgets + */ + suspend fun reloadWidgets(widgetIds: List?) = + withContext(Dispatchers.Main) { + if (widgetIds != null && widgetIds.isNotEmpty()) { + Log.d(TAG, "reloadWidgets: specific widgets ${widgetIds.joinToString()}") + for (widgetId in widgetIds) { + try { + updateWidget(widgetId) + } catch (e: Exception) { + Log.e(TAG, "Failed to reload widget $widgetId: ${e.message}") + } + } + } else { + Log.d(TAG, "reloadWidgets: all widgets") + reloadAllWidgets() + } + } + + /** + * Reload all widgets by finding all saved widget data + */ + suspend fun reloadAllWidgets() = + withContext(Dispatchers.Main) { + Log.d(TAG, "reloadAllWidgets") + + // Get all widget IDs from saved data + val allKeys = prefs.all.keys + val widgetIds = + allKeys + .filter { it.startsWith(KEY_JSON_PREFIX) } + .map { it.removePrefix(KEY_JSON_PREFIX) } + .toSet() + + Log.d(TAG, "Found ${widgetIds.size} widgets with saved data: $widgetIds") + + for (widgetId in widgetIds) { + try { + updateWidget(widgetId) + } catch (e: Exception) { + Log.e(TAG, "Failed to update widget $widgetId: ${e.message}") + } + } + } +} diff --git a/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt b/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt new file mode 100644 index 0000000..4af7b68 --- /dev/null +++ b/android/src/main/java/voltra/widget/VoltraWidgetReceiver.kt @@ -0,0 +1,28 @@ +package voltra.widget + +import android.util.Log +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +/** + * Base widget receiver for Voltra home screen widgets. + * Handles widget lifecycle events and updates. + * + * Generated widget receivers extend this class and provide their widgetId. + */ +abstract class VoltraWidgetReceiver : GlanceAppWidgetReceiver() { + companion object { + private const val TAG = "VoltraWidgetReceiver" + } + + /** + * The unique identifier for this widget. + * Must be provided by subclasses. + */ + abstract val widgetId: String + + override val glanceAppWidget: GlanceAppWidget by lazy { + Log.d(TAG, "Creating VoltraGlanceWidget for widgetId=$widgetId") + VoltraGlanceWidget(widgetId) + } +} diff --git a/android/src/main/res/xml/voltra_file_paths.xml b/android/src/main/res/xml/voltra_file_paths.xml new file mode 100644 index 0000000..be3da43 --- /dev/null +++ b/android/src/main/res/xml/voltra_file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/components.json b/data/components.json index 3f7536a..97e3c55 100644 --- a/data/components.json +++ b/data/components.json @@ -47,8 +47,22 @@ "type": "typ", "value": "v", "weight": "wt", - + "maxLines": "mxl", + "contentDescription": "cdesc", + "horizontalAlignment": "halig", + "verticalAlignment": "valig", + "contentAlignment": "ca", + "icon": "ic", + "contentColor": "cc", + "horizontalPadding": "hp", + "assetName": "an", + "base64": "b64", + "checked": "chk", + "enabled": "en", + "id": "id", + "deepLinkUrl": "dlu", "padding": "pad", + "text": "txt", "paddingVertical": "pv", "paddingHorizontal": "ph", "paddingTop": "pt", @@ -84,6 +98,8 @@ "minHeight": "minh", "maxHeight": "maxh", "flexGrowWidth": "fgw", + "fillMaxWidth": "fmw", + "fillMaxHeight": "fmh", "fixedSizeHorizontal": "fsh", "fixedSizeVertical": "fsv", "layoutPriority": "lp", @@ -103,7 +119,6 @@ "textDecorationLine": "tdl", "glassEffect": "ge", "transform": "tf", - "frame": "f", "offset": "off", "foregroundStyle": "fgs", @@ -162,6 +177,8 @@ "maxWidth", "minHeight", "maxHeight", + "fillMaxWidth", + "fillMaxHeight", "flexGrowWidth", "fixedSizeHorizontal", "fixedSizeVertical", @@ -216,6 +233,46 @@ } } }, + { + "name": "AndroidFilledButton", + "description": "Android Material Design filled button component for widgets", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": { + "text": { + "type": "string", + "optional": false, + "description": "Text to display" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the button is enabled" + }, + "icon": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Optional icon" + }, + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Background color" + }, + "contentColor": { + "type": "string", + "optional": true, + "description": "Text/icon color" + }, + "maxLines": { + "type": "number", + "optional": true, + "description": "Maximum lines for text" + } + } + }, { "name": "Label", "description": "Label with optional icon", @@ -234,22 +291,23 @@ } }, { - "name": "Image", - "description": "Display images from asset catalog or base64 data", - "swiftAvailability": "iOS 13.0, macOS 10.15", + "name": "AndroidImage", + "description": "Android Image component", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", "parameters": { "source": { "type": "object", - "optional": true, + "optional": false, "jsonEncoded": true, - "description": "Image source - either { assetName: string } for asset catalog images or { base64: string } for base64 encoded images" + "description": "Image source" }, "resizeMode": { "type": "string", "optional": true, "enum": ["cover", "contain", "stretch", "repeat", "center"], "default": "cover", - "description": "How the image should be resized to fit its container" + "description": "Resizing mode" } } }, @@ -328,6 +386,81 @@ } } }, + { + "name": "AndroidSwitch", + "description": "Android Switch component", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": { + "id": { + "type": "string", + "optional": false, + "description": "Unique identifier for interaction events" + }, + "checked": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Initial checked state" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the switch is enabled" + } + } + }, + { + "name": "AndroidCheckBox", + "description": "Android CheckBox component", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": { + "id": { + "type": "string", + "optional": false, + "description": "Unique identifier for interaction events" + }, + "checked": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Initial checked state" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the checkbox is enabled" + } + } + }, + { + "name": "AndroidRadioButton", + "description": "Android RadioButton component", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": { + "id": { + "type": "string", + "optional": false, + "description": "Unique identifier for interaction events" + }, + "checked": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Initial checked state" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the radio button is enabled" + } + } + }, { "name": "LinearProgressView", "description": "Linear progress indicator (determinate or timer-based)", @@ -692,6 +825,324 @@ "description": "Voltra element used as the mask - alpha channel determines visibility" } } + }, + { + "name": "AndroidBox", + "description": "Android Box container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "contentAlignment": { + "type": "string", + "optional": true, + "enum": [ + "top-start", + "top-center", + "top-end", + "center-start", + "center", + "center-end", + "bottom-start", + "bottom-center", + "bottom-end" + ], + "description": "Content alignment within the box" + } + } + }, + { + "name": "AndroidButton", + "description": "Android Button component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the button is enabled" + } + } + }, + { + "name": "AndroidCircleIconButton", + "description": "Android Circle Icon Button component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "icon": { + "type": "object", + "optional": false, + "jsonEncoded": true, + "description": "Icon source" + }, + "contentDescription": { + "type": "string", + "optional": true, + "description": "Accessibility description" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the button is enabled" + }, + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Background color" + }, + "contentColor": { + "type": "string", + "optional": true, + "description": "Icon color" + } + } + }, + { + "name": "AndroidCircularProgressIndicator", + "description": "Android Circular Progress Indicator", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "color": { + "type": "string", + "optional": true, + "description": "Progress color" + } + } + }, + { + "name": "AndroidColumn", + "description": "Android Column container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "horizontalAlignment": { + "type": "string", + "optional": true, + "enum": ["start", "center-horizontally", "end"], + "description": "Horizontal alignment of children" + }, + "verticalAlignment": { + "type": "string", + "optional": true, + "enum": ["top", "center-vertically", "bottom"], + "description": "Vertical alignment of children" + } + } + }, + { + "name": "AndroidLazyColumn", + "description": "Android LazyColumn container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "horizontalAlignment": { + "type": "string", + "optional": true, + "enum": ["start", "center-horizontally", "end"], + "description": "Horizontal alignment of children" + } + } + }, + { + "name": "AndroidLazyVerticalGrid", + "description": "Android LazyVerticalGrid container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": {} + }, + { + "name": "AndroidLinearProgressIndicator", + "description": "Android Linear Progress Indicator", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "color": { + "type": "string", + "optional": true, + "description": "Progress color" + }, + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Track background color" + } + } + }, + { + "name": "AndroidOutlineButton", + "description": "Android Outline Button component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "text": { + "type": "string", + "optional": false, + "description": "Text to display" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the button is enabled" + }, + "icon": { + "type": "object", + "optional": true, + "jsonEncoded": true, + "description": "Optional icon" + }, + "contentColor": { + "type": "string", + "optional": true, + "description": "Text/icon color" + }, + "maxLines": { + "type": "number", + "optional": true, + "description": "Maximum lines for text" + } + } + }, + { + "name": "AndroidRow", + "description": "Android Row container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "horizontalAlignment": { + "type": "string", + "optional": true, + "enum": ["start", "center-horizontally", "end"], + "description": "Horizontal alignment of children" + }, + "verticalAlignment": { + "type": "string", + "optional": true, + "enum": ["top", "center-vertically", "bottom"], + "description": "Vertical alignment of children" + } + } + }, + { + "name": "AndroidScaffold", + "description": "Android Scaffold container", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "hasChildren": true, + "parameters": { + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Background color" + }, + "horizontalPadding": { + "type": "number", + "optional": true, + "description": "Horizontal padding" + } + } + }, + { + "name": "AndroidSpacer", + "description": "Android Spacer component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": {} + }, + { + "name": "AndroidSquareIconButton", + "description": "Android Square Icon Button component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "icon": { + "type": "object", + "optional": false, + "jsonEncoded": true, + "description": "Icon source" + }, + "contentDescription": { + "type": "string", + "optional": true, + "description": "Accessibility description" + }, + "enabled": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Whether the button is enabled" + }, + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Background color" + }, + "contentColor": { + "type": "string", + "optional": true, + "description": "Icon color" + } + } + }, + { + "name": "AndroidText", + "description": "Android Text component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "text": { + "type": "string", + "optional": false, + "description": "Text content" + }, + "color": { + "type": "string", + "optional": true, + "description": "Text color" + }, + "fontSize": { + "type": "number", + "optional": true, + "description": "Font size" + }, + "maxLines": { + "type": "number", + "optional": true, + "description": "Maximum lines" + } + } + }, + { + "name": "AndroidTitleBar", + "description": "Android Title Bar component", + "androidAvailability": "Android 12+", + "swiftAvailability": "Not available", + "parameters": { + "title": { + "type": "string", + "optional": false, + "description": "Title text" + }, + "backgroundColor": { + "type": "string", + "optional": true, + "description": "Background color" + }, + "contentColor": { + "type": "string", + "optional": true, + "description": "Text color" + } + } } ] } diff --git a/example/app.json b/example/app.json index 55bae56..d840ece 100644 --- a/example/app.json +++ b/example/app.json @@ -2,17 +2,28 @@ "expo": { "name": "Voltra Example", "slug": "voltra-example", - "scheme": "voltraexample", + "scheme": "voltra", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/voltra-icon.jpg", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", "newArchEnabled": true, + "androidNavigationBar": { + "visible": "edgeToEdge", + "barStyle": "light-content" + }, "splash": { "image": "./assets/voltra-splash.jpg", "resizeMode": "cover", "backgroundColor": "#8232FF" }, + "android": { + "package": "com.callstackincubator.voltraexample", + "adaptiveIcon": { + "foregroundImage": "./assets/voltra-icon.jpg", + "backgroundColor": "#8232FF" + } + }, "ios": { "supportsTablet": true, "bundleIdentifier": "com.callstackincubator.voltraexample", @@ -32,7 +43,41 @@ "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], "initialStatePath": "./widgets/weather-initial.tsx" } - ] + ], + "android": { + "widgets": [ + { + "id": "voltra", + "displayName": "Voltra Widget", + "description": "Voltra logo widget", + "minCellWidth": 2, + "minCellHeight": 2, + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android-voltra-widget-initial.tsx" + }, + { + "id": "interactive_todos", + "displayName": "Interactive Todos Widget", + "description": "Testing interactive widgets with checkboxes, switches, and buttons", + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen" + }, + { + "id": "image_preloading", + "displayName": "Image Preloading Widget", + "description": "Test image preloading on Android", + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen" + } + ] + } } ], "expo-router", diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 3f4ea5f..c421c6e 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,7 +1,11 @@ import { Stack } from 'expo-router' +import { SafeAreaProvider } from 'react-native-safe-area-context' import { BackgroundWrapper } from '~/components/BackgroundWrapper' import { useVoltraEvents } from '~/hooks/useVoltraEvents' +import { updateAndroidVoltraWidget } from '~/widgets/updateAndroidVoltraWidget' + +updateAndroidVoltraWidget({ width: 300, height: 200 }) const STACK_SCREEN_OPTIONS = { headerShown: false, @@ -9,28 +13,31 @@ const STACK_SCREEN_OPTIONS = { } export const unstable_settings = { - initialRouteName: 'live-activities', + initialRouteName: 'index', } export default function Layout() { useVoltraEvents() return ( - {children}} - > - - - - - + + {children}} + > + + + + + + + ) } diff --git a/example/app/android-widgets.tsx b/example/app/android-widgets.tsx new file mode 100644 index 0000000..c33ebf7 --- /dev/null +++ b/example/app/android-widgets.tsx @@ -0,0 +1,5 @@ +import AndroidScreen from '~/screens/android/AndroidScreen' + +export default function AndroidWidgetsIndex() { + return +} diff --git a/example/app/android-widgets/image-preloading.tsx b/example/app/android-widgets/image-preloading.tsx new file mode 100644 index 0000000..9407336 --- /dev/null +++ b/example/app/android-widgets/image-preloading.tsx @@ -0,0 +1,5 @@ +import AndroidImagePreloadingScreen from '~/screens/android/AndroidImagePreloadingScreen' + +export default function AndroidImagePreloadingIndex() { + return +} diff --git a/example/app/android-widgets/interactive.tsx b/example/app/android-widgets/interactive.tsx new file mode 100644 index 0000000..50d5c32 --- /dev/null +++ b/example/app/android-widgets/interactive.tsx @@ -0,0 +1,5 @@ +import AndroidInteractiveWidgetScreen from '~/screens/android/AndroidInteractiveWidgetScreen' + +export default function AndroidInteractiveWidgetIndex() { + return +} diff --git a/example/app/android-widgets/pin.tsx b/example/app/android-widgets/pin.tsx new file mode 100644 index 0000000..03a5222 --- /dev/null +++ b/example/app/android-widgets/pin.tsx @@ -0,0 +1,5 @@ +import AndroidWidgetPinScreen from '~/screens/android/AndroidWidgetPinScreen' + +export default function AndroidWidgetPinIndex() { + return +} diff --git a/example/app/android-widgets/preview.tsx b/example/app/android-widgets/preview.tsx new file mode 100644 index 0000000..dc4370d --- /dev/null +++ b/example/app/android-widgets/preview.tsx @@ -0,0 +1,5 @@ +import AndroidPreviewScreen from '~/screens/android/AndroidPreviewScreen' + +export default function AndroidPreviewIndex() { + return +} diff --git a/example/app/index.tsx b/example/app/index.tsx index a8bc8b9..6f8cf7a 100644 --- a/example/app/index.tsx +++ b/example/app/index.tsx @@ -1,5 +1,7 @@ import { Redirect } from 'expo-router' +import { Platform } from 'react-native' export default function Index() { - return + const href = Platform.OS === 'android' ? '/android-widgets' : '/live-activities' + return } diff --git a/example/assets/voltra-android/voltra-logo.svg b/example/assets/voltra-android/voltra-logo.svg new file mode 100644 index 0000000..5369d6c --- /dev/null +++ b/example/assets/voltra-android/voltra-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/example/components/BackgroundWrapper.tsx b/example/components/BackgroundWrapper.tsx index 011f533..d9363f0 100644 --- a/example/components/BackgroundWrapper.tsx +++ b/example/components/BackgroundWrapper.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react' import { Image, StyleSheet, useWindowDimensions, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' export type BackgroundWrapperProps = { children: ReactNode @@ -15,7 +16,7 @@ export const BackgroundWrapper = ({ children }: BackgroundWrapperProps) => { style={[styles.image, { width, height }]} resizeMode="cover" /> - {children} + {children} ) } @@ -24,6 +25,9 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + safeArea: { + flex: 1, + }, image: { position: 'absolute', top: 0, diff --git a/example/hooks/useVoltraEvents.ts b/example/hooks/useVoltraEvents.ts index a988693..b73f064 100644 --- a/example/hooks/useVoltraEvents.ts +++ b/example/hooks/useVoltraEvents.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { Platform } from 'react-native' import { addVoltraListener } from 'voltra/client' export const useVoltraEvents = (): void => { @@ -11,6 +12,8 @@ export const useVoltraEvents = (): void => { }, []) useEffect(() => { + if (Platform.OS !== 'ios') return + const subscription = addVoltraListener('activityPushToStartTokenReceived', (event) => { console.log('Activity push to start token received:', event) }) @@ -19,6 +22,8 @@ export const useVoltraEvents = (): void => { }, []) useEffect(() => { + if (Platform.OS !== 'ios') return + const subscription = addVoltraListener('activityTokenReceived', (event) => { console.log('Activity token received:', event) }) @@ -27,6 +32,8 @@ export const useVoltraEvents = (): void => { }, []) useEffect(() => { + if (Platform.OS !== 'ios') return + const subscription = addVoltraListener('stateChange', (event) => { console.log('Activity update:', event) }) diff --git a/example/screens/android/AndroidImagePreloadingScreen.tsx b/example/screens/android/AndroidImagePreloadingScreen.tsx new file mode 100644 index 0000000..ec7152f --- /dev/null +++ b/example/screens/android/AndroidImagePreloadingScreen.tsx @@ -0,0 +1,223 @@ +import { useRouter } from 'expo-router' +import React, { useState } from 'react' +import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native' +import { VoltraAndroid } from 'voltra' +import { clearPreloadedImages, preloadImages, reloadWidgets, updateAndroidWidget } from 'voltra/android/client' + +import { Button } from '~/components/Button' +import { TextInput } from '~/components/TextInput' + +function generateRandomUrl(): string { + return `https://picsum.photos/id/${Math.floor(Math.random() * 200)}/300/200` +} + +export default function AndroidImagePreloadingScreen() { + const router = useRouter() + const [url, setUrl] = useState(generateRandomUrl()) + const [isProcessing, setIsProcessing] = useState(false) + const [assetKey] = useState('android-preload-test') + const [updateCount, setUpdateCount] = useState(0) + + const handleUpdateAndPreload = async () => { + if (!url.trim()) { + Alert.alert('Error', 'Please enter a URL') + return + } + + setIsProcessing(true) + + try { + // 1. Preload the image first + console.log('Preloading image:', url) + const result = await preloadImages([ + { + url: url.trim(), + key: assetKey, + }, + ]) + + if (result.failed.length > 0) { + throw new Error(result.failed[0].error) + } + + console.log('Preload successful, updating widget...') + + // 2. Update the widget to use the preloaded image + await updateAndroidWidget('image_preloading', [ + { + size: { width: 300, height: 200 }, + content: ( + + + + Preloaded Image Test + + + + + + + Updates: {updateCount + 1} + + + + + ), + }, + ]) + + setUpdateCount((prev) => prev + 1) + Alert.alert('Success', 'Image preloaded and widget updated!') + setUrl(generateRandomUrl()) + } catch (error) { + Alert.alert('Error', `Failed to process: ${error}`) + } finally { + setIsProcessing(false) + } + } + + const handleOverwriteAndReload = async () => { + setIsProcessing(true) + try { + const nextUrl = generateRandomUrl() + console.log('Overwriting image with same key but new URL:', nextUrl) + + const result = await preloadImages([ + { + url: nextUrl, + key: assetKey, + }, + ]) + + if (result.failed.length > 0) { + throw new Error(result.failed[0].error) + } + + console.log('Preload successful, reloading widgets...') + + // On Android, reloadWidgets (alias for reloadAndroidWidgets) + // will force Glance to re-render, which will call extractImageProvider + // and pick up the new URI from SharedPreferences. + await reloadWidgets(['image_preloading']) + + Alert.alert('Success', 'Image overwritten and widget reloaded!') + } catch (error) { + Alert.alert('Error', `Failed to overwrite: ${error}`) + } finally { + setIsProcessing(false) + } + } + + const handleClearImages = async () => { + try { + await clearPreloadedImages([assetKey]) + Alert.alert('Success', 'Preloaded images cleared') + } catch (error) { + Alert.alert('Error', `Failed to clear images: ${error}`) + } + } + + return ( + + + Android Image Preloading + + Test preloading images for Android widgets. Enter a URL to preload an image and update the widget, or + overwrite existing preloaded images. + + + + Image URL + + + + +