Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 72 additions & 18 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,27 @@
"widgets": [
{
"id": "weather",
"displayName": "Weather Widget",
"description": "Shows current weather conditions",
"displayName": {
"pl": "Widget pogody",
"en": "Weather Widget"
},
"description": {
"pl": "Pokazuje aktualne warunki pogodowe",
"en": "Shows current weather conditions"
},
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/ios/ios-weather-initial.tsx"
},
{
"id": "portfolio",
"displayName": "Portfolio Widget",
"description": "Shows portfolio performance with a chart and live updates",
"displayName": {
"pl": "Widget portfela",
"en": "Portfolio Widget"
},
"description": {
"pl": "Pokazuje wyniki portfela z wykresem i aktualizacjami na żywo",
"en": "Shows portfolio performance with a chart and live updates"
},
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/ios/ios-portfolio-initial.tsx",
"serverUpdate": {
Expand All @@ -60,8 +72,14 @@
"widgets": [
{
"id": "voltra",
"displayName": "Voltra Widget",
"description": "Voltra logo widget",
"displayName": {
"pl": "Widget Voltra",
"en": "Voltra Widget"
},
"description": {
"pl": "Widget z logo Voltra",
"en": "Voltra logo widget"
},
"minCellWidth": 2,
"minCellHeight": 2,
"targetCellWidth": 2,
Expand All @@ -73,8 +91,14 @@
},
{
"id": "interactive_todos",
"displayName": "Interactive Todos Widget",
"description": "Testing interactive widgets with checkboxes, switches, and buttons",
"displayName": {
"pl": "Interaktywna lista zadań",
"en": "Interactive Todos Widget"
},
"description": {
"pl": "Test interaktywnych widgetów z polami wyboru, przełącznikami i przyciskami",
"en": "Testing interactive widgets with checkboxes, switches, and buttons"
},
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
Expand All @@ -83,17 +107,29 @@
},
{
"id": "image_preloading",
"displayName": "Image Preloading Widget",
"description": "Test image preloading on Android",
"displayName": {
"pl": "Widget wstępnego ładowania obrazów",
"en": "Image Preloading Widget"
},
"description": {
"pl": "Test wstępnego ładowania obrazów na Androidzie",
"en": "Test image preloading on Android"
},
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen"
},
{
"id": "image_fallback",
"displayName": "Image Fallback Widget",
"description": "Test image fallback with backgroundColor from styles",
"displayName": {
"pl": "Widget zapasowego obrazu",
"en": "Image Fallback Widget"
},
"description": {
"pl": "Test zapasowego obrazu z kolorem tła ze stylów",
"en": "Test image fallback with backgroundColor from styles"
},
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
Expand All @@ -102,8 +138,14 @@
},
{
"id": "chart_widget",
"displayName": "Chart Widget",
"description": "Test Chart component",
"displayName": {
"pl": "Widget wykresu",
"en": "Chart Widget"
},
"description": {
"pl": "Test komponentu Chart",
"en": "Test Chart component"
},
"targetCellWidth": 3,
"targetCellHeight": 3,
"resizeMode": "horizontal|vertical",
Expand All @@ -112,8 +154,14 @@
},
{
"id": "portfolio",
"displayName": "Portfolio Widget",
"description": "Shows portfolio performance with a chart and live updates",
"displayName": {
"pl": "Widget portfela",
"en": "Portfolio Widget"
},
"description": {
"pl": "Pokazuje wyniki portfela z wykresem i aktualizacjami na żywo",
"en": "Shows portfolio performance with a chart and live updates"
},
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
Expand All @@ -127,8 +175,14 @@
},
{
"id": "material_colors",
"displayName": "Material Colors Widget",
"description": "Compare client-side and server-side rendering with Android dynamic colors",
"displayName": {
"pl": "Widget kolorów Material",
"en": "Material Colors Widget"
},
"description": {
"pl": "Porównanie renderowania po stronie klienta i serwera z dynamicznymi kolorami Androida",
"en": "Compare client-side and server-side rendering with Android dynamic colors"
},
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
Expand Down
6 changes: 4 additions & 2 deletions packages/expo-plugin/src/android/files/kotlin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'fs'
import * as path from 'path'

import type { AndroidWidgetConfig } from '../../types'
import { widgetLabelEnglish } from '../../utils/widgetLabel'

export interface GenerateKotlinFilesProps {
platformProjectRoot: string
Expand Down Expand Up @@ -49,6 +50,7 @@ export async function generateWidgetReceivers(props: GenerateKotlinFilesProps):
*/
function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: string): string {
const className = `VoltraWidget_${widget.id}Receiver`
const labelForComment = widgetLabelEnglish(widget.displayName)

if (widget.serverUpdate) {
const refreshEnabled = widget.serverUpdate.refresh === true
Expand All @@ -62,7 +64,7 @@ function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: s
import voltra.widget.VoltraWidgetUpdateScheduler

/**
* Auto-generated widget receiver for ${widget.displayName}
* Auto-generated widget receiver for ${labelForComment}
* Widget ID: ${widget.id}
* Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes ?? 15} minutes)
* Refresh Button: ${refreshEnabled}
Expand Down Expand Up @@ -110,7 +112,7 @@ function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: s
import voltra.widget.VoltraWidgetReceiver

/**
* Auto-generated widget receiver for ${widget.displayName}
* Auto-generated widget receiver for ${labelForComment}
* Widget ID: ${widget.id}
*/
class ${className} : VoltraWidgetReceiver() {
Expand Down
153 changes: 140 additions & 13 deletions packages/expo-plugin/src/android/files/xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path'

import type { AndroidWidgetConfig } from '../../types'
import { logger } from '../../utils/logger'
import { isWidgetLocalizedMap, widgetLabelEnglish } from '../../utils/widgetLabel'

export interface GenerateXmlFilesProps {
platformProjectRoot: string
Expand All @@ -25,7 +26,8 @@ export async function generateWidgetInfoFiles(props: {
}): Promise<void> {
const { platformProjectRoot, widgets } = props
const xmlPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'xml')
const valuesPath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'values')
const mainRes = path.join(platformProjectRoot, 'app', 'src', 'main', 'res')
const valuesPath = path.join(mainRes, 'values')

// Ensure directories exist
if (!fs.existsSync(xmlPath)) {
Expand All @@ -35,10 +37,30 @@ export async function generateWidgetInfoFiles(props: {
fs.mkdirSync(valuesPath, { recursive: true })
}

// Generate string resources for all widgets
const stringsPath = path.join(valuesPath, 'voltra_widgets.xml')
const stringsContent = generateStringResourcesXml(widgets)
fs.writeFileSync(stringsPath, stringsContent, 'utf8')
// Default strings (development / fallback language — prefers `en` in locale maps): res/values/voltra_widgets.xml
const stringsPath = path.join(valuesPath, VOLTRA_WIDGET_STRINGS_FILE)
fs.writeFileSync(stringsPath, generateVoltraWidgetsStringResourcesXml(widgets, null), 'utf8')

const localeKeys = collectAndroidLocaleKeysFromWidgets(widgets)
/** Qualifiers we generated under res/values-<qualifier>/ (Android resource folder suffixes) */
const generatedQualifiers = new Set<string>()

for (const localeKey of localeKeys) {
const qualifier = localeKeyToAndroidValuesQualifier(localeKey)
if (qualifier === DEFAULT_WIDGET_LOCALE_QUALIFIER) {
continue
}
generatedQualifiers.add(qualifier)
const localizedValuesDir = path.join(mainRes, `values-${qualifier}`)
fs.mkdirSync(localizedValuesDir, { recursive: true })
fs.writeFileSync(
path.join(localizedValuesDir, VOLTRA_WIDGET_STRINGS_FILE),
generateVoltraWidgetsStringResourcesXml(widgets, localeKey),
'utf8'
)
}

cleanupStaleVoltraWidgetLocaleFiles(mainRes, generatedQualifiers)
}

/**
Expand Down Expand Up @@ -131,29 +153,134 @@ function generateWidgetInfoXml(
}

// ============================================================================
// String Resources XML
// String Resources XML (multilingual: res/values/ + res/values-<locale>/)
// https://stackoverflow.com/questions/47976576/working-with-strings-xml-and-translations
// https://developer.android.com/guide/topics/resources/localization
// ============================================================================

const VOLTRA_WIDGET_STRINGS_FILE = 'voltra_widgets.xml'

/** Locale maps use `en` for default English; unqualified `values/` covers that so skip `values-en/`. */
const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en'

/**
* Generates string resources for widget display names and descriptions
* Maps BCP-style locale keys from app.json to Android resource folder qualifiers.
* Examples: `pl` → `pl`, `pt-BR` / `pt_BR` → `pt-rBR`
*/
function generateStringResourcesXml(widgets: AndroidWidgetConfig[]): string {
function localeKeyToAndroidValuesQualifier(localeKey: string): string {
const normalized = localeKey.trim().replace(/_/g, '-')
const segments = normalized.split('-').filter(Boolean)
if (segments.length >= 2 && segments[1].length >= 2) {
const lang = segments[0].toLowerCase()
const region = segments[1].toUpperCase()
return `${lang}-r${region}`
}
return segments[0]?.toLowerCase() ?? normalized.toLowerCase()
}

function collectAndroidLocaleKeysFromWidgets(widgets: AndroidWidgetConfig[]): Set<string> {
const locales = new Set<string>()
for (const w of widgets) {
for (const field of ['displayName', 'description'] as const) {
const v = w[field]
if (isWidgetLocalizedMap(v)) {
for (const [localeKey, text] of Object.entries(v)) {
if (typeof text === 'string' && text.trim()) {
locales.add(localeKey)
}
}
}
}
}
return locales
}

function resolveAndroidWidgetLabel(
widget: AndroidWidgetConfig,
field: 'displayName' | 'description',
localeKey: string | null
): string {
const v = widget[field]
if (!isWidgetLocalizedMap(v)) {
return v
}
if (localeKey !== null) {
const localized = v[localeKey]
if (typeof localized === 'string' && localized.trim()) {
return localized
}
}
return widgetLabelEnglish(v)
}

function escapeAndroidStringRes(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/\"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}

function generateVoltraWidgetsStringResourcesXml(widgets: AndroidWidgetConfig[], localeKey: string | null): string {
const localeComment =
localeKey === null ? 'default (values/)' : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}`

const stringEntries = widgets
.map(
(widget) =>
`<string name="voltra_widget_${widget.id}_label">${widget.displayName}</string>\n <string name="voltra_widget_${widget.id}_description">${widget.description}</string>`
)
.map((widget) => {
const label = escapeAndroidStringRes(resolveAndroidWidgetLabel(widget, 'displayName', localeKey))
const desc = escapeAndroidStringRes(resolveAndroidWidgetLabel(widget, 'description', localeKey))
return `<string name="voltra_widget_${widget.id}_label">${label}</string>\n <string name="voltra_widget_${widget.id}_description">${desc}</string>`
})
.join('\n ')

return dedent`
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Voltra Widget Labels and Descriptions (Auto-generated) -->
<!-- Voltra widget picker strings (auto-generated). ${localeComment} -->
${stringEntries}
</resources>
`
}

/**
* Removes generated voltra_widgets.xml from values-<qualifier>/ folders we no longer use.
*/
function cleanupStaleVoltraWidgetLocaleFiles(mainRes: string, activeQualifiers: Set<string>): void {
if (!fs.existsSync(mainRes)) {
return
}

for (const entry of fs.readdirSync(mainRes)) {
if (!entry.startsWith('values-')) {
continue
}

const qualifier = entry.slice('values-'.length)
const stringsFile = path.join(mainRes, entry, VOLTRA_WIDGET_STRINGS_FILE)
if (!fs.existsSync(stringsFile)) {
continue
}

if (activeQualifiers.has(qualifier)) {
continue
}

fs.unlinkSync(stringsFile)
try {
const dir = path.join(mainRes, entry)
if (fs.readdirSync(dir).length === 0) {
fs.rmdirSync(dir)
}
} catch {
/* ignore */
}
}
}

// ============================================================================
// Placeholder Layout XML
// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,7 @@ export type {
VoltraConfigPlugin,
WidgetConfig,
WidgetFamily,
WidgetLabel,
WidgetLocalizedCopy,
WidgetServerUpdateConfig,
} from './types'
Loading
Loading