Skip to content
Open
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
52 changes: 52 additions & 0 deletions android/src/main/java/voltra/VoltraModule.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package voltra

import android.appwidget.AppWidgetManager
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -171,6 +172,57 @@ class VoltraModule : Module() {
Log.d(TAG, "clearAllAndroidWidgets completed")
}

AsyncFunction("getActiveWidgets") {
val context = appContext.reactContext ?: return@AsyncFunction emptyList<Map<String, Any>>()
val manager = AppWidgetManager.getInstance(context)
val packageName = context.packageName

// 1. Get all providers defined in this app
val installedProviders =
manager.installedProviders.filter {
it.provider.packageName == packageName
}

val activeWidgets = mutableListOf<Map<String, Any>>()

// 2. Iterate over every provider
for (providerInfo in installedProviders) {
val ids = manager.getAppWidgetIds(providerInfo.provider)

// 3. Iterate over every instance of that widget
for (id in ids) {
val options = manager.getAppWidgetOptions(id)
val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)

// Get short class name (e.g. ".widget.VoltraWidget_weatherReceiver")
val shortClassName = providerInfo.provider.shortClassName

val prefix = ".widget.VoltraWidget_"
val suffix = "Receiver"
val name =
if (shortClassName.startsWith(prefix) && shortClassName.endsWith(suffix)) {
shortClassName.substring(prefix.length, shortClassName.length - suffix.length)
} else {
shortClassName
}

activeWidgets.add(
mapOf(
"name" to name,
"widgetId" to id,
"providerClassName" to shortClassName,
"label" to providerInfo.loadLabel(context.packageManager).toString(),
"width" to minWidth,
"height" to minHeight,
),
)
}
}

activeWidgets
}

AsyncFunction("requestPinGlanceAppWidget") {
widgetId: String,
options: Map<String, Any?>?,
Expand Down
121 changes: 121 additions & 0 deletions example/components/ActiveWidgetsAndroidCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { getActiveWidgets, WidgetInfo } from 'voltra/android/client'

import { Card } from './Card'

export function ActiveWidgetsAndroidCard() {
const [widgets, setWidgets] = useState<WidgetInfo[]>([])
const [loading, setLoading] = useState(true)

const refreshWidgets = useCallback(async () => {
setLoading(true)
try {
const activeWidgets = await getActiveWidgets()
setWidgets(activeWidgets)
} catch (error) {
console.error('Failed to fetch active widgets:', error)
} finally {
setLoading(false)
}
}, [])

useEffect(() => {
refreshWidgets()
}, [refreshWidgets])

return (
<Card>
<View style={styles.header}>
<Card.Title>Active Widgets</Card.Title>
<TouchableOpacity onPress={refreshWidgets} disabled={loading} style={styles.refreshButton}>
{loading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.refreshText}>Refresh</Text>
)}
</TouchableOpacity>
</View>

<Card.Text>Currently active widget instances on your home screen.</Card.Text>

{widgets.length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scroll}>
{widgets.map((widget) => (
<View key={`${widget.widgetId}`} style={styles.widgetItem}>
<Text style={styles.widgetName}>{widget.name}</Text>
<Text style={styles.widgetId}>ID: {widget.widgetId}</Text>
<Text style={styles.widgetDetails}>
{widget.width}x{widget.height}dp
</Text>
</View>
))}
</ScrollView>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{loading ? 'Fetching widgets...' : 'No active widgets found'}</Text>
</View>
)}
</Card>
)
}

const styles = StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
refreshButton: {
paddingVertical: 4,
paddingHorizontal: 12,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 12,
},
refreshText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
scroll: {
marginTop: 12,
},
widgetItem: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 12,
padding: 12,
marginRight: 12,
width: 140,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
widgetName: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 16,
marginBottom: 4,
},
widgetId: {
color: '#CBD5F5',
fontSize: 11,
},
widgetDetails: {
color: '#CBD5F5',
fontSize: 11,
marginTop: 2,
},
provider: {
color: 'rgba(203, 213, 245, 0.5)',
fontSize: 10,
marginTop: 8,
},
emptyState: {
padding: 20,
alignItems: 'center',
},
emptyText: {
color: '#CBD5F5',
fontStyle: 'italic',
},
})
114 changes: 114 additions & 0 deletions example/components/ActiveWidgetsIOSCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { getActiveWidgets, WidgetInfo } from 'voltra/client'

import { Card } from './Card'

export function ActiveWidgetsIOSCard() {
const [widgets, setWidgets] = useState<WidgetInfo[]>([])
const [loading, setLoading] = useState(true)

const refreshWidgets = useCallback(async () => {
setLoading(true)
try {
const activeWidgets = await getActiveWidgets()
setWidgets(activeWidgets)
} catch (error) {
console.error('Failed to fetch active widgets:', error)
} finally {
setLoading(false)
}
}, [])

useEffect(() => {
refreshWidgets()
}, [refreshWidgets])

return (
<Card>
<View style={styles.header}>
<Card.Title>Active Home Widgets</Card.Title>
<TouchableOpacity onPress={refreshWidgets} disabled={loading} style={styles.refreshButton}>
{loading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.refreshText}>Refresh</Text>
)}
</TouchableOpacity>
</View>

<Card.Text>Active Home Screen widget configurations for this app.</Card.Text>

{widgets.length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scroll}>
{widgets.map((widget, index) => (
<View key={`${widget.kind}-${index}`} style={styles.widgetItem}>
<Text style={styles.widgetName}>{widget.name}</Text>
<Text style={styles.widgetFamily}>{widget.family}</Text>
</View>
))}
</ScrollView>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{loading ? 'Fetching widgets...' : 'No active widgets found'}</Text>
</View>
)}
</Card>
)
}

const styles = StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
refreshButton: {
paddingVertical: 4,
paddingHorizontal: 12,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 12,
},
refreshText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
scroll: {
marginTop: 12,
},
widgetItem: {
backgroundColor: 'rgba(130, 50, 255, 0.1)',
borderRadius: 12,
padding: 12,
marginRight: 12,
width: 140,
borderWidth: 1,
borderColor: 'rgba(130, 50, 255, 0.2)',
},
widgetName: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 16,
marginBottom: 4,
},
widgetFamily: {
color: '#CBD5F5',
fontSize: 11,
fontWeight: '600',
},
kind: {
color: 'rgba(203, 213, 245, 0.5)',
fontSize: 10,
marginTop: 8,
},
emptyState: {
padding: 20,
alignItems: 'center',
},
emptyText: {
color: '#CBD5F5',
fontStyle: 'italic',
},
})
3 changes: 3 additions & 0 deletions example/screens/android/AndroidScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRouter } from 'expo-router'
import React from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'

import { ActiveWidgetsAndroidCard } from '~/components/ActiveWidgetsAndroidCard'
import { Button } from '~/components/Button'
import { Card } from '~/components/Card'

Expand Down Expand Up @@ -41,6 +42,8 @@ export default function AndroidScreen() {
write Kotlin or XML anymore.
</Text>

<ActiveWidgetsAndroidCard />

{ANDROID_SECTIONS.map((section) => (
<Card key={section.id}>
<Card.Title>{section.title}</Card.Title>
Expand Down
3 changes: 3 additions & 0 deletions example/screens/live-activities/LiveActivitiesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { endAllLiveActivities } from 'voltra/client'

import { Button } from '~/components/Button'
import { Card } from '~/components/Card'
import { ActiveWidgetsIOSCard } from '~/components/ActiveWidgetsIOSCard'
import { NotificationsCard } from '~/components/NotificationsCard'
import BasicLiveActivity from '~/screens/live-activities/BasicLiveActivity'
import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity'
Expand Down Expand Up @@ -208,6 +209,8 @@ export default function LiveActivitiesScreen() {
</Link>
</View>

<ActiveWidgetsIOSCard />

<NotificationsCard />

{CARD_ORDER.map(renderCard)}
Expand Down
4 changes: 4 additions & 0 deletions ios/app/VoltraModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ public class VoltraModule: Module {
await self.impl.clearAllWidgets()
}

AsyncFunction("getActiveWidgets") { () async throws -> [[String: String]] in
return try await self.impl.getActiveWidgets()
}

View(VoltraRN.self) {
Prop("payload") { (view, payload: String) in
view.setPayload(payload)
Expand Down
38 changes: 38 additions & 0 deletions ios/app/VoltraModuleImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,46 @@ public class VoltraModuleImpl {
print("[Voltra] Cleared all widgets")
}

func getActiveWidgets() async throws -> [[String: String]] {
try await withCheckedThrowingContinuation { continuation in
WidgetCenter.shared.getCurrentConfigurations { result in
switch result {
case let .success(widgetInfos):
let mapped = widgetInfos.map { widget -> [String: String] in
let prefix = "Voltra_Widget_"
let name = widget.kind.hasPrefix(prefix)
? String(widget.kind.dropFirst(prefix.count))
: widget.kind

return [
"name": name,
"kind": widget.kind,
"family": self.mapWidgetFamily(widget.family),
]
}
continuation.resume(returning: mapped)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}

// MARK: - Private Helpers

private func mapWidgetFamily(_ family: WidgetFamily) -> String {
switch family {
case .systemSmall: return "systemSmall"
case .systemMedium: return "systemMedium"
case .systemLarge: return "systemLarge"
case .systemExtraLarge: return "systemExtraLarge"
case .accessoryCircular: return "accessoryCircular"
case .accessoryRectangular: return "accessoryRectangular"
case .accessoryInline: return "accessoryInline"
@unknown default: return "unknown"
}
}

private func mapError(_ error: Error) -> Error {
if let serviceError = error as? VoltraLiveActivityError {
switch serviceError {
Expand Down
Loading
Loading