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..055cc89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,3 +88,25 @@ jobs: - name: Check Swift formatting run: npm run format:swift:check + + # TODO: Add ktlint back at some point (it takes over 2 minutes to install) + # 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: Install ktlint + # run: | + # eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + # brew install ktlint + + # - 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/README.md b/README.md index 359429d..2ba905c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ ![voltra-banner](https://use-voltra.dev/voltra-baner.jpg) -### Build Live Activities with JSX in React Native +### Build Live Activities and Widgets with JSX in React Native [![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome] -Voltra turns React Native JSX into SwiftUI so you can ship custom Live Activities, Dynamic Island layouts without touching Xcode. Author everything in React, keep hot reload, and let the config plugin handle the extension targets. +Voltra turns React Native JSX into SwiftUI and Jetpack Compose Glance so you can ship custom Live Activities, Dynamic Island layouts, and Android Widgets without touching native code. Author everything in React, keep hot reload, and let the config plugin handle the native extension targets. ## Features -- **Ship Native iOS Surfaces**: Create Live Activities, Dynamic Island variants, and static widgets directly from React components - no Swift or Xcode required. +- **Ship Native Surfaces**: Create iOS Live Activities, Dynamic Island variants, and Android Home Screen widgets directly from React components - no Swift, Kotlin, or Xcode/Android Studio UI work required. -- **Fast Development Workflow**: Hooks respect Fast Refresh and both JS and native layers enforce ActivityKit payload budgets. +- **Fast Development Workflow**: Hooks respect Fast Refresh and both JS and native layers enforce platform-specific payload budgets. -- **Production-Ready Push Notifications**: Collect ActivityKit push tokens and push-to-start tokens, stream lifecycle updates, and build server-driven refreshes. +- **Production-Ready Push Notifications**: Support for ActivityKit push tokens (iOS) and FCM (Android) to stream lifecycle updates and build server-driven refreshes. -- **Familiar Styling**: Use React Native style props and ordered SwiftUI modifiers in one place. +- **Familiar Styling**: Use React Native style props and platform-native modifiers (SwiftUI/Glance) in one place. - **Type-Safe & Developer-Friendly**: The Voltra schema, hooks, and examples ship with TypeScript definitions, tests, and docs so AI coding agents stay productive. -- **Works With Your Setup**: Compatible with Expo Dev Client and bare React Native projects. The config plugin automatically wires iOS extension targets for you. +- **Works With Your Setup**: Compatible with Expo Dev Client and bare React Native projects. The config plugin automatically wires native extension targets for you. ## Documentation @@ -50,7 +50,7 @@ Add the config plugin to your `app.json`: } ``` -Then run `npx expo prebuild --clean` to generate the iOS extension target. +Then run `npx expo prebuild --clean` to generate the native extension targets. See the [documentation](https://use-voltra.dev/getting-started/quick-start) for detailed setup instructions. @@ -83,7 +83,10 @@ export function OrderTracker({ orderId }: { orderId: string }) { ## Platform compatibility -**Note:** This module is intended for use on **iOS devices only**. +Voltra is a cross-platform library that supports: + +- **iOS**: Live Activities and Dynamic Island (SwiftUI). +- **Android**: Home Screen Widgets (Jetpack Compose Glance). ## Authors diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..87f1ee7 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,84 @@ +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' + +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", 31) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "voltra" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + 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 new file mode 100644 index 0000000..97f6aec --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/java/voltra/VoltraModule.kt b/android/src/main/java/voltra/VoltraModule.kt new file mode 100644 index 0000000..68f0d88 --- /dev/null +++ b/android/src/main/java/voltra/VoltraModule.kt @@ -0,0 +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 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() { + companion object { + private const val TAG = "VoltraModule" + } + + private val notificationManager by lazy { + VoltraNotificationManager(appContext.reactContext!!) + } + + private val widgetManager by lazy { + VoltraWidgetManager(appContext.reactContext!!) + } + + private val imageManager by lazy { + VoltraImageManager(appContext.reactContext!!) + } + + 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/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..758536b --- /dev/null +++ b/android/src/main/java/voltra/VoltraRN.kt @@ -0,0 +1,206 @@ +package voltra + +import android.content.Context +import android.view.View +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) + } + + // CRITICAL FIX: Manually trigger layout for the new content. + // Since we are adding this async, the parent won't do it for us automatically. + frameLayout.measure( + View.MeasureSpec.makeMeasureSpec(frameLayout.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(frameLayout.height, View.MeasureSpec.EXACTLY), + ) + frameLayout.layout( + frameLayout.left, + frameLayout.top, + frameLayout.right, + frameLayout.bottom, + ) + + // 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..109166f --- /dev/null +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -0,0 +1,166 @@ +// +// 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", + "ff" to "fontFamily", + "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", + "shrs" to "showHours", + "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 = 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..2565fba --- /dev/null +++ b/android/src/main/java/voltra/glance/StyleUtils.kt @@ -0,0 +1,126 @@ +package voltra.glance + +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import voltra.glance.renderers.getOnClickAction +import voltra.payload.ComponentTypeID +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 ComponentTypeID) + * @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( + ComponentTypeID.FILLED_BUTTON, + ComponentTypeID.OUTLINE_BUTTON, + ComponentTypeID.CIRCLE_ICON_BUTTON, + ComponentTypeID.SQUARE_ICON_BUTTON, + ComponentTypeID.SWITCH, + ComponentTypeID.RADIO_BUTTON, + ComponentTypeID.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..467ad9f --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt @@ -0,0 +1,219 @@ +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..597a79e --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt @@ -0,0 +1,189 @@ +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.glance.LocalVoltraRenderContext +import voltra.glance.applyClickableIfNeeded +import voltra.glance.resolveAndApplyStyle +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.payload.ComponentTypeID +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 == ComponentTypeID.TITLE_BAR + } + + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + resolved is VoltraNode.Element && resolved.element.t == ComponentTypeID.TITLE_BAR + } + + else -> { + false + } + } + } + + val bodyElements = + children.elements.filter { child -> + when (child) { + is VoltraNode.Element -> { + child.element.t != ComponentTypeID.TITLE_BAR + } + + is VoltraNode.Ref -> { + val resolved = context.sharedElements?.getOrNull(child.ref) + !(resolved is VoltraNode.Element && resolved.element.t == ComponentTypeID.TITLE_BAR) + } + + else -> { + true + } + } + } + val body = if (bodyElements.isNotEmpty()) VoltraNode.Array(bodyElements) else null + + titleBar to body + } + + is VoltraNode.Element -> { + if (children.element.t == ComponentTypeID.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..7152c79 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/LayoutRenderers.kt @@ -0,0 +1,259 @@ +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..6da9e20 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt @@ -0,0 +1,149 @@ +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..419e840 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -0,0 +1,221 @@ +package voltra.glance.renderers + +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.action.Action +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.layout.ContentScale +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import voltra.glance.LocalVoltraRenderContext +import voltra.images.VoltraImageManager +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.payload.ComponentTypeID +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) { + ComponentTypeID.TEXT -> RenderText(element) + ComponentTypeID.COLUMN -> RenderColumn(element) + ComponentTypeID.ROW -> RenderRow(element) + ComponentTypeID.BOX -> RenderBox(element) + ComponentTypeID.SPACER -> RenderSpacer(element) + ComponentTypeID.IMAGE -> RenderImage(element) + ComponentTypeID.BUTTON -> RenderButton(element) + ComponentTypeID.LINEAR_PROGRESS_INDICATOR -> RenderLinearProgress(element) + ComponentTypeID.CIRCULAR_PROGRESS_INDICATOR -> RenderCircularProgress(element) + ComponentTypeID.SWITCH -> RenderSwitch(element) + ComponentTypeID.RADIO_BUTTON -> RenderRadioButton(element) + ComponentTypeID.CHECK_BOX -> RenderCheckBox(element) + ComponentTypeID.FILLED_BUTTON -> RenderFilledButton(element) + ComponentTypeID.OUTLINE_BUTTON -> RenderOutlineButton(element) + ComponentTypeID.CIRCLE_ICON_BUTTON -> RenderCircleIconButton(element) + ComponentTypeID.SQUARE_ICON_BUTTON -> RenderSquareIconButton(element) + ComponentTypeID.TITLE_BAR -> RenderTitleBar(element) + ComponentTypeID.SCAFFOLD -> RenderScaffold(element) + ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element) + ComponentTypeID.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) { + ComponentTypeID.TEXT -> RenderText(element, modifier, compositeStyle) + ComponentTypeID.COLUMN -> RenderColumn(element, modifier) + ComponentTypeID.ROW -> RenderRow(element, modifier) + ComponentTypeID.BOX -> RenderBox(element, modifier) + ComponentTypeID.SPACER -> RenderSpacer(element, modifier) + ComponentTypeID.IMAGE -> RenderImage(element, modifier) + ComponentTypeID.BUTTON -> RenderButton(element, modifier) + ComponentTypeID.LINEAR_PROGRESS_INDICATOR -> RenderLinearProgress(element, modifier) + ComponentTypeID.CIRCULAR_PROGRESS_INDICATOR -> RenderCircularProgress(element, modifier) + ComponentTypeID.SWITCH -> RenderSwitch(element, modifier) + ComponentTypeID.RADIO_BUTTON -> RenderRadioButton(element, modifier) + ComponentTypeID.CHECK_BOX -> RenderCheckBox(element, modifier) + ComponentTypeID.FILLED_BUTTON -> RenderFilledButton(element, modifier) + ComponentTypeID.OUTLINE_BUTTON -> RenderOutlineButton(element, modifier) + ComponentTypeID.CIRCLE_ICON_BUTTON -> RenderCircleIconButton(element, modifier) + ComponentTypeID.SQUARE_ICON_BUTTON -> RenderSquareIconButton(element, modifier) + ComponentTypeID.TITLE_BAR -> RenderTitleBar(element, modifier) + ComponentTypeID.SCAFFOLD -> RenderScaffold(element, modifier) + ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element, modifier) + ComponentTypeID.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..83060e7 --- /dev/null +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -0,0 +1,114 @@ +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..e7b86c9 --- /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..2d8b7cb --- /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..6443ff5 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidCheckBoxParameters.kt @@ -0,0 +1,24 @@ +// +// 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..6cca1e2 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidCircleIconButtonParameters.kt @@ -0,0 +1,28 @@ +// +// 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..c249198 --- /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..03499a5 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidColumnParameters.kt @@ -0,0 +1,22 @@ +// +// 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..00fa187 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidFilledButtonParameters.kt @@ -0,0 +1,30 @@ +// +// 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..9c808ec --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt @@ -0,0 +1,22 @@ +// +// 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..9507bf5 --- /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..3406702 --- /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..21338eb --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidLinearProgressIndicatorParameters.kt @@ -0,0 +1,22 @@ +// +// 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..2e25983 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidOutlineButtonParameters.kt @@ -0,0 +1,28 @@ +// +// 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..d4e3c9c --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidRadioButtonParameters.kt @@ -0,0 +1,24 @@ +// +// 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..0ff1424 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidRowParameters.kt @@ -0,0 +1,22 @@ +// +// 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..5725a39 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidScaffoldParameters.kt @@ -0,0 +1,22 @@ +// +// 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..3ee2638 --- /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..df074b7 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidSquareIconButtonParameters.kt @@ -0,0 +1,28 @@ +// +// 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..65aca25 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidSwitchParameters.kt @@ -0,0 +1,24 @@ +// +// 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..96406f5 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidTextParameters.kt @@ -0,0 +1,26 @@ +// +// 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..16ae254 --- /dev/null +++ b/android/src/main/java/voltra/models/parameters/AndroidTitleBarParameters.kt @@ -0,0 +1,24 @@ +// +// 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..fca1743 --- /dev/null +++ b/android/src/main/java/voltra/parsing/VoltraDecompressor.kt @@ -0,0 +1,56 @@ +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 = + 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 = + 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 = + 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..5804071 --- /dev/null +++ b/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt @@ -0,0 +1,43 @@ +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..16238e6 --- /dev/null +++ b/android/src/main/java/voltra/payload/ComponentTypeID.kt @@ -0,0 +1,63 @@ +// +// 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 { + const val FILLED_BUTTON = 0 + const val IMAGE = 1 + const val SWITCH = 2 + const val CHECK_BOX = 3 + const val RADIO_BUTTON = 4 + const val BOX = 5 + const val BUTTON = 6 + const val CIRCLE_ICON_BUTTON = 7 + const val CIRCULAR_PROGRESS_INDICATOR = 8 + const val COLUMN = 9 + const val LAZY_COLUMN = 10 + const val LAZY_VERTICAL_GRID = 11 + const val LINEAR_PROGRESS_INDICATOR = 12 + const val OUTLINE_BUTTON = 13 + const val ROW = 14 + const val SCAFFOLD = 15 + const val SPACER = 16 + const val SQUARE_ICON_BUTTON = 17 + const val TEXT = 18 + const val TITLE_BAR = 19 + + /** + * Get component name from numeric ID + */ + fun getComponentName(id: Int): String? = + 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..052b15d --- /dev/null +++ b/android/src/main/java/voltra/styling/JSColorParser.kt @@ -0,0 +1,255 @@ +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..a5af30e --- /dev/null +++ b/android/src/main/java/voltra/styling/JSStyleParser.kt @@ -0,0 +1,338 @@ +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..1cbe5c8 --- /dev/null +++ b/android/src/main/java/voltra/styling/StyleConverter.kt @@ -0,0 +1,255 @@ +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..d08fac0 --- /dev/null +++ b/android/src/main/java/voltra/styling/StyleModifiers.kt @@ -0,0 +1,312 @@ +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 99c16ef..32ac349 100644 --- a/data/components.json +++ b/data/components.json @@ -48,8 +48,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", @@ -86,6 +100,8 @@ "minHeight": "minh", "maxHeight": "maxh", "flexGrowWidth": "fgw", + "fillMaxWidth": "fmw", + "fillMaxHeight": "fmh", "fixedSizeHorizontal": "fsh", "fixedSizeVertical": "fsv", "layoutPriority": "lp", @@ -105,7 +121,6 @@ "textDecorationLine": "tdl", "glassEffect": "ge", "transform": "tf", - "frame": "f", "offset": "off", "foregroundStyle": "fgs", @@ -165,6 +180,8 @@ "maxWidth", "minHeight", "maxHeight", + "fillMaxWidth", + "fillMaxHeight", "flexGrowWidth", "fixedSizeHorizontal", "fixedSizeVertical", @@ -219,6 +236,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", @@ -256,6 +313,27 @@ } } }, + { + "name": "AndroidImage", + "description": "Android Image component", + "swiftAvailability": "Not available", + "androidAvailability": "Android 12+", + "parameters": { + "source": { + "type": "object", + "optional": false, + "jsonEncoded": true, + "description": "Image source" + }, + "resizeMode": { + "type": "string", + "optional": true, + "enum": ["cover", "contain", "stretch", "repeat", "center"], + "default": "cover", + "description": "Resizing mode" + } + } + }, { "name": "Symbol", "description": "Display SF Symbols with advanced configuration", @@ -331,6 +409,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)", @@ -701,6 +854,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/__tests__/__image_snapshots__/ios/music-player-live-activity-preview.png b/example/__tests__/__image_snapshots__/ios/music-player-live-activity-preview.png deleted file mode 100644 index 25aecea..0000000 Binary files a/example/__tests__/__image_snapshots__/ios/music-player-live-activity-preview.png and /dev/null differ diff --git a/example/__tests__/__image_snapshots__/ios/basic-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/basic-live-activity-preview.png similarity index 61% rename from example/__tests__/__image_snapshots__/ios/basic-live-activity-preview.png rename to example/__tests__/ios/__image_snapshots__/ios/basic-live-activity-preview.png index a2a1630..0575055 100644 Binary files a/example/__tests__/__image_snapshots__/ios/basic-live-activity-preview.png and b/example/__tests__/ios/__image_snapshots__/ios/basic-live-activity-preview.png differ diff --git a/example/__tests__/__image_snapshots__/ios/compass-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/compass-live-activity-preview.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/compass-live-activity-preview.png rename to example/__tests__/ios/__image_snapshots__/ios/compass-live-activity-preview.png diff --git a/example/__tests__/__image_snapshots__/ios/flight-tracker-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/flight-tracker-live-activity-preview.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/flight-tracker-live-activity-preview.png rename to example/__tests__/ios/__image_snapshots__/ios/flight-tracker-live-activity-preview.png diff --git a/example/__tests__/__image_snapshots__/ios/liquid-glass-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/liquid-glass-live-activity-preview.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/liquid-glass-live-activity-preview.png rename to example/__tests__/ios/__image_snapshots__/ios/liquid-glass-live-activity-preview.png diff --git a/example/__tests__/ios/__image_snapshots__/ios/music-player-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/music-player-live-activity-preview.png new file mode 100644 index 0000000..64635a6 Binary files /dev/null and b/example/__tests__/ios/__image_snapshots__/ios/music-player-live-activity-preview.png differ diff --git a/example/__tests__/__image_snapshots__/ios/weather-widget-rainy.png b/example/__tests__/ios/__image_snapshots__/ios/weather-widget-rainy.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/weather-widget-rainy.png rename to example/__tests__/ios/__image_snapshots__/ios/weather-widget-rainy.png diff --git a/example/__tests__/__image_snapshots__/ios/weather-widget-snowy.png b/example/__tests__/ios/__image_snapshots__/ios/weather-widget-snowy.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/weather-widget-snowy.png rename to example/__tests__/ios/__image_snapshots__/ios/weather-widget-snowy.png diff --git a/example/__tests__/__image_snapshots__/ios/weather-widget-stormy.png b/example/__tests__/ios/__image_snapshots__/ios/weather-widget-stormy.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/weather-widget-stormy.png rename to example/__tests__/ios/__image_snapshots__/ios/weather-widget-stormy.png diff --git a/example/__tests__/__image_snapshots__/ios/weather-widget-sunny.png b/example/__tests__/ios/__image_snapshots__/ios/weather-widget-sunny.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/weather-widget-sunny.png rename to example/__tests__/ios/__image_snapshots__/ios/weather-widget-sunny.png diff --git a/example/__tests__/__image_snapshots__/ios/workout-tracker-live-activity-preview.png b/example/__tests__/ios/__image_snapshots__/ios/workout-tracker-live-activity-preview.png similarity index 100% rename from example/__tests__/__image_snapshots__/ios/workout-tracker-live-activity-preview.png rename to example/__tests__/ios/__image_snapshots__/ios/workout-tracker-live-activity-preview.png diff --git a/example/__tests__/live-activity-snapshots.harness.tsx b/example/__tests__/ios/live-activity-snapshots.harness.tsx similarity index 99% rename from example/__tests__/live-activity-snapshots.harness.tsx rename to example/__tests__/ios/live-activity-snapshots.harness.tsx index faef079..3912588 100644 --- a/example/__tests__/live-activity-snapshots.harness.tsx +++ b/example/__tests__/ios/live-activity-snapshots.harness.tsx @@ -11,7 +11,7 @@ import { MusicPlayerLiveActivityUI, SONGS, WorkoutLiveActivityUI, -} from '../components/live-activities' +} from '../../components/live-activities' describe('Live Activity snapshots', () => { const previewWrapperStyle = { diff --git a/example/__tests__/widget-snapshots.harness.tsx b/example/__tests__/ios/widget-snapshots.harness.tsx similarity index 96% rename from example/__tests__/widget-snapshots.harness.tsx rename to example/__tests__/ios/widget-snapshots.harness.tsx index aeb4d04..2941b46 100644 --- a/example/__tests__/widget-snapshots.harness.tsx +++ b/example/__tests__/ios/widget-snapshots.harness.tsx @@ -3,8 +3,8 @@ import { View } from 'react-native' import { afterAll, beforeAll, describe, expect, Mock, render, spyOn, test } from 'react-native-harness' import { VoltraWidgetPreview } from 'voltra/client' -import { SAMPLE_WEATHER_DATA } from '../widgets/weather-types' -import { WeatherWidget } from '../widgets/WeatherWidget' +import { SAMPLE_WEATHER_DATA } from '../../widgets/weather-types' +import { WeatherWidget } from '../../widgets/WeatherWidget' describe('Widget snapshots', () => { const mockDate = new Date('2026-01-20T08:00:00Z') diff --git a/example/app.json b/example/app.json index d19aa8a..b061383 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", @@ -36,6 +47,42 @@ "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", + "previewImage": "./assets/voltra-icon.jpg" + }, + { + "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", + "previewLayout": "./assets/widgets/todos-preview.xml" + }, + { + "id": "image_preloading", + "displayName": "Image Preloading Widget", + "description": "Test image preloading on Android", + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen" + } + ] + }, "fonts": [ "node_modules/@expo-google-fonts/merriweather/400Regular/Merriweather_400Regular.ttf", "node_modules/@expo-google-fonts/merriweather/700Bold/Merriweather_700Bold.ttf" 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/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/assets/widgets/todos-preview.xml b/example/assets/widgets/todos-preview.xml new file mode 100644 index 0000000..47bdaaf --- /dev/null +++ b/example/assets/widgets/todos-preview.xml @@ -0,0 +1,17 @@ + + + + + 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..286886c 100644 --- a/example/hooks/useVoltraEvents.ts +++ b/example/hooks/useVoltraEvents.ts @@ -1,8 +1,11 @@ import { useEffect } from 'react' +import { Platform } from 'react-native' import { addVoltraListener } from 'voltra/client' export const useVoltraEvents = (): void => { useEffect(() => { + if (Platform.OS !== 'ios') return + const subscription = addVoltraListener('interaction', (event) => { console.log('Voltra event:', event) }) @@ -11,6 +14,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 +24,8 @@ export const useVoltraEvents = (): void => { }, []) useEffect(() => { + if (Platform.OS !== 'ios') return + const subscription = addVoltraListener('activityTokenReceived', (event) => { console.log('Activity token received:', event) }) @@ -27,6 +34,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/package.json b/example/package.json index 7927fdd..a032f92 100644 --- a/example/package.json +++ b/example/package.json @@ -8,8 +8,8 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "harness:ios": "react-native-harness --harnessRunner=ios", - "harness:android": "react-native-harness --harnessRunner=android", + "harness:ios": "react-native-harness ./__tests__/ios --harnessRunner=ios", + "harness:android": "react-native-harness ./__tests__/android --harnessRunner=android", "postinstall": "patch-package" }, "dependencies": { 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 + + + + +