diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ed7471c..66c00162 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { implementation(projects.core.data) implementation(projects.core.sync) implementation(projects.core.opml) + implementation(projects.widget) implementation(projects.ui.resources) implementation(projects.ui.preview) implementation(projects.ui.designSystem) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bdb40c2..89bcb5b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ compose-bom = "2024.06.00" compose-bom-alpha = "2024.07.00-alpha01" compose-htmlconverter = "0.9.5" kmpalette = "3.1.0" +glance = "1.1.0" lyricist = "1.7.0" navigation = "2.8.0-beta05" molecule = "2.0.0" @@ -77,6 +78,9 @@ lyricist = { group = "cafe.adriel.lyricist", name = "lyricist", version.ref = "l lyricist-processor = { group = "cafe.adriel.lyricist", name = "lyricist-processor", version.ref = "lyricist" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +glance = { module = "androidx.glance:glance", version.ref = "glance" } +glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } @@ -179,6 +183,11 @@ compose = [ "aboutlibraries-m3", "compose-htmlconverter" ] +glance = [ + "glance", + "glance-appwidget", + "glance-material3" +] navigation = [ "navigation", "hilt-navigation-compose" diff --git a/settings.gradle.kts b/settings.gradle.kts index fd98ffa9..5a0f3184 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,3 +31,4 @@ include(":core:opml") include(":ui:resources") include(":ui:preview") include(":ui:design-system") +include(":widget") diff --git a/widget/build.gradle.kts b/widget/build.gradle.kts new file mode 100644 index 00000000..14397a3e --- /dev/null +++ b/widget/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.podcaster.compose.android.lib) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.mr3y.podcaster.widget" +} + +dependencies { + ksp(libs.hilt.compiler) + implementation(libs.hilt.runtime) + + implementation(libs.bundles.glance) + implementation(libs.material3) + implementation(libs.coil.mp) + + implementation(projects.core.data) + implementation(projects.core.logger) + implementation(projects.core.model) + implementation(projects.ui.resources) + implementation(projects.ui.designSystem) +} \ No newline at end of file diff --git a/widget/src/main/AndroidManifest.xml b/widget/src/main/AndroidManifest.xml new file mode 100644 index 00000000..06e6f091 --- /dev/null +++ b/widget/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/widget/src/main/kotlin/com/mr3y/podcaster/widget/Color.kt b/widget/src/main/kotlin/com/mr3y/podcaster/widget/Color.kt new file mode 100644 index 00000000..f4831c0d --- /dev/null +++ b/widget/src/main/kotlin/com/mr3y/podcaster/widget/Color.kt @@ -0,0 +1,126 @@ +package com.mr3y.podcaster.widget + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.mr3y.podcaster.ui.theme.md_theme_dark_background +import com.mr3y.podcaster.ui.theme.md_theme_dark_error +import com.mr3y.podcaster.ui.theme.md_theme_dark_errorContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_inverseOnSurface +import com.mr3y.podcaster.ui.theme.md_theme_dark_inversePrimary +import com.mr3y.podcaster.ui.theme.md_theme_dark_inverseSurface +import com.mr3y.podcaster.ui.theme.md_theme_dark_onBackground +import com.mr3y.podcaster.ui.theme.md_theme_dark_onError +import com.mr3y.podcaster.ui.theme.md_theme_dark_onErrorContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_onPrimary +import com.mr3y.podcaster.ui.theme.md_theme_dark_onPrimaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_onSecondary +import com.mr3y.podcaster.ui.theme.md_theme_dark_onSecondaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_onSurface +import com.mr3y.podcaster.ui.theme.md_theme_dark_onSurfaceVariant +import com.mr3y.podcaster.ui.theme.md_theme_dark_onTertiary +import com.mr3y.podcaster.ui.theme.md_theme_dark_onTertiaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_outline +import com.mr3y.podcaster.ui.theme.md_theme_dark_outlineVariant +import com.mr3y.podcaster.ui.theme.md_theme_dark_primary +import com.mr3y.podcaster.ui.theme.md_theme_dark_primaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_scrim +import com.mr3y.podcaster.ui.theme.md_theme_dark_secondary +import com.mr3y.podcaster.ui.theme.md_theme_dark_secondaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_dark_surface +import com.mr3y.podcaster.ui.theme.md_theme_dark_surfaceTint +import com.mr3y.podcaster.ui.theme.md_theme_dark_surfaceVariant +import com.mr3y.podcaster.ui.theme.md_theme_dark_tertiary +import com.mr3y.podcaster.ui.theme.md_theme_dark_tertiaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_background +import com.mr3y.podcaster.ui.theme.md_theme_light_error +import com.mr3y.podcaster.ui.theme.md_theme_light_errorContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_inverseOnSurface +import com.mr3y.podcaster.ui.theme.md_theme_light_inversePrimary +import com.mr3y.podcaster.ui.theme.md_theme_light_inverseSurface +import com.mr3y.podcaster.ui.theme.md_theme_light_onBackground +import com.mr3y.podcaster.ui.theme.md_theme_light_onError +import com.mr3y.podcaster.ui.theme.md_theme_light_onErrorContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_onPrimary +import com.mr3y.podcaster.ui.theme.md_theme_light_onPrimaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_onSecondary +import com.mr3y.podcaster.ui.theme.md_theme_light_onSecondaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_onSurface +import com.mr3y.podcaster.ui.theme.md_theme_light_onSurfaceVariant +import com.mr3y.podcaster.ui.theme.md_theme_light_onTertiary +import com.mr3y.podcaster.ui.theme.md_theme_light_onTertiaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_outline +import com.mr3y.podcaster.ui.theme.md_theme_light_outlineVariant +import com.mr3y.podcaster.ui.theme.md_theme_light_primary +import com.mr3y.podcaster.ui.theme.md_theme_light_primaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_scrim +import com.mr3y.podcaster.ui.theme.md_theme_light_secondary +import com.mr3y.podcaster.ui.theme.md_theme_light_secondaryContainer +import com.mr3y.podcaster.ui.theme.md_theme_light_surface +import com.mr3y.podcaster.ui.theme.md_theme_light_surfaceTint +import com.mr3y.podcaster.ui.theme.md_theme_light_surfaceVariant +import com.mr3y.podcaster.ui.theme.md_theme_light_tertiary +import com.mr3y.podcaster.ui.theme.md_theme_light_tertiaryContainer + +internal val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + +internal val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) diff --git a/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidget.kt b/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidget.kt new file mode 100644 index 00000000..de32207d --- /dev/null +++ b/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidget.kt @@ -0,0 +1,315 @@ +package com.mr3y.podcaster.widget + +import android.content.Context +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.material3.ColorProviders +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import coil3.Bitmap +import coil3.BitmapImage +import coil3.Image +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.size.Scale +import com.mr3y.podcaster.core.data.PodcastsRepository +import com.mr3y.podcaster.core.logger.Logger +import com.mr3y.podcaster.core.model.CurrentlyPlayingEpisode +import com.mr3y.podcaster.core.model.PlayingStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class PodcasterAppWidget( + private val podcastsRepository: PodcastsRepository, + private val logger: Logger +) : GlanceAppWidget() { + + override val sizeMode: SizeMode = SizeMode.Responsive(setOf(Small, Normal)) + + enum class SizeBucket { Invalid, Narrow, Normal } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + + provideContent { + val currentlyPlayingEpisode by podcastsRepository.getCurrentlyPlayingEpisode().collectAsState(initial = null) + val sizeBucket = calculateSizeBucket() + + GlanceTheme( + colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + GlanceTheme.colors + else + ColorProviders(light = LightColors, dark = DarkColors) + ) { + when (sizeBucket) { + SizeBucket.Invalid -> InvalidSizeUI() + SizeBucket.Narrow -> { + WidgetShallowSize( + playingStatus = currentlyPlayingEpisode?.playingStatus, + onPlayClick = { }, + onPauseClick = { }, + modifier = GlanceModifier.fillMaxSize() + ) + } + SizeBucket.Normal -> { + WidgetNormalSize( + activeEpisode = currentlyPlayingEpisode, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + } + } + } + + @Composable + private fun WidgetNormalSize( + activeEpisode: CurrentlyPlayingEpisode?, + modifier: GlanceModifier = GlanceModifier + ) { + val context = LocalContext.current.applicationContext + Scaffold( + backgroundColor = GlanceTheme.colors.surface, + modifier = modifier.clickable { + /*val launcherIntent = context.packageManager.getLaunchIntentForPackage("com.mr3y.podcaster") + if (launcherIntent != null) { + context.startActivity(launcherIntent) + }*/ + actionStartActivity() + } + ) { + if (activeEpisode == null) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = context.getString(R.string.no_episode_playing), + style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onSurface), + ) + } + } else { + Row( + modifier = GlanceModifier.padding(vertical = 8.dp).fillMaxSize(), + ) { + AsyncImage( + model = activeEpisode.episode.artworkUrl, + contentDescription = null, + modifier = GlanceModifier.fillMaxHeight() + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + Column( + modifier = GlanceModifier.defaultWeight() + ) { + Text( + text = activeEpisode.episode.title, + style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onSurface), + maxLines = 3, + modifier = GlanceModifier.defaultWeight(), + ) + Text( + text = activeEpisode.episode.podcastTitle ?: "", + style = TextStyle(fontSize = 14.sp, color = GlanceTheme.colors.onSurfaceVariant), + maxLines = 2, + modifier = GlanceModifier.defaultWeight(), + ) + + ControlButtons( + playingStatus = activeEpisode.playingStatus, + onPlayClick = { }, + onPauseClick = {}, + onGoToPreviousClick = {}, + onGoToNextClick = {}, + modifier = GlanceModifier.fillMaxWidth(), + ) + } + } + } + } + } + + @Composable + private fun WidgetShallowSize( + playingStatus: PlayingStatus?, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + modifier: GlanceModifier = GlanceModifier, + ) { + Scaffold( + backgroundColor = GlanceTheme.colors.surface, + modifier = modifier.clickable { + actionStartActivity() + } + ) { + val context = LocalContext.current + if (playingStatus == null) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = context.getString(R.string.no_episode_playing), + style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onSurface), + ) + } + } else { + val icon = if (playingStatus == PlayingStatus.Paused) R.drawable.outline_play_arrow_24 else R.drawable.outline_pause_24 + SquareIconButton( + imageProvider = ImageProvider(icon), + contentDescription = null, + onClick = if (playingStatus == PlayingStatus.Paused) onPlayClick else onPauseClick + ) + } + } + } + + @OptIn(ExperimentalCoilApi::class) + @Composable + private fun AsyncImage( + model: Any, + contentDescription: String?, + modifier: GlanceModifier = GlanceModifier + ) { + var bitmap by remember { mutableStateOf(null) } + val context = LocalContext.current + + LaunchedEffect(key1 = model) { + val request = ImageRequest.Builder(context) + .data(model) + .size(200, 200) + .scale(Scale.FILL) + .target { image: Image -> + bitmap = (image as BitmapImage).bitmap + } + .build() + + launch(Dispatchers.IO) { + val result = ImageLoader(context).execute(request) + if (result is ErrorResult) { + val t = result.throwable + logger.e(t, tag = TAG) { "Image Request Error:" } + } + } + } + + bitmap?.let { + Image( + provider = ImageProvider(it), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = modifier.cornerRadius(12.dp), + ) + } + } + + @Composable + private fun ControlButtons( + playingStatus: PlayingStatus, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + onGoToPreviousClick: () -> Unit, + onGoToNextClick: () -> Unit, + modifier: GlanceModifier = GlanceModifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + CircleIconButton( + imageProvider = ImageProvider(R.drawable.outline_skip_prev_24), + contentDescription = null, + onClick = onGoToPreviousClick + ) + + Spacer(modifier = GlanceModifier.defaultWeight()) + + val icon = if (playingStatus == PlayingStatus.Paused) R.drawable.outline_play_arrow_24 else R.drawable.outline_pause_24 + CircleIconButton( + imageProvider = ImageProvider(icon), + contentDescription = null, + onClick = if (playingStatus == PlayingStatus.Paused) onPlayClick else onPauseClick + ) + + Spacer(modifier = GlanceModifier.defaultWeight()) + + CircleIconButton( + imageProvider = ImageProvider(R.drawable.outline_skip_next_24), + contentDescription = null, + onClick = onGoToNextClick + ) + } + } + + @Composable + private fun InvalidSizeUI() { + Box( + modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.surface), + contentAlignment = Alignment.Center + ) { + val context = LocalContext.current + Text( + text = context.getString(R.string.invalid_size), + style = TextStyle(fontSize = 14.sp, color = GlanceTheme.colors.onSurface), + ) + } + } + + @Composable + private fun calculateSizeBucket(): SizeBucket { + val size: DpSize = LocalSize.current + val width = size.width + + return when { + width < Small.width -> SizeBucket.Invalid + width <= Normal.width -> SizeBucket.Narrow + else -> SizeBucket.Normal + } + } + + companion object { + const val TAG = "PodcasterAppWidget" + val Small = DpSize(128.dp, 48.dp) + val Normal = DpSize(256.dp, 124.dp) + } +} diff --git a/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidgetReceiver.kt b/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidgetReceiver.kt new file mode 100644 index 00000000..eef62d5f --- /dev/null +++ b/widget/src/main/kotlin/com/mr3y/podcaster/widget/PodcasterAppWidgetReceiver.kt @@ -0,0 +1,21 @@ +package com.mr3y.podcaster.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.mr3y.podcaster.core.data.PodcastsRepository +import com.mr3y.podcaster.core.logger.Logger +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class PodcasterAppWidgetReceiver : GlanceAppWidgetReceiver() { + + @Inject + lateinit var podcastsRepository: PodcastsRepository + + @Inject + lateinit var logger: Logger + + override val glanceAppWidget: GlanceAppWidget + get() = PodcasterAppWidget(podcastsRepository, logger) +} diff --git a/widget/src/main/res/drawable/outline_pause_24.xml b/widget/src/main/res/drawable/outline_pause_24.xml new file mode 100644 index 00000000..9b16bde4 --- /dev/null +++ b/widget/src/main/res/drawable/outline_pause_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/widget/src/main/res/drawable/outline_play_arrow_24.xml b/widget/src/main/res/drawable/outline_play_arrow_24.xml new file mode 100644 index 00000000..91ab3ac1 --- /dev/null +++ b/widget/src/main/res/drawable/outline_play_arrow_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/widget/src/main/res/drawable/outline_skip_next_24.xml b/widget/src/main/res/drawable/outline_skip_next_24.xml new file mode 100644 index 00000000..a5b6207c --- /dev/null +++ b/widget/src/main/res/drawable/outline_skip_next_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/widget/src/main/res/drawable/outline_skip_prev_24.xml b/widget/src/main/res/drawable/outline_skip_prev_24.xml new file mode 100644 index 00000000..9e4f956b --- /dev/null +++ b/widget/src/main/res/drawable/outline_skip_prev_24.xml @@ -0,0 +1,13 @@ + + + diff --git a/widget/src/main/res/values/strings.xml b/widget/src/main/res/values/strings.xml new file mode 100644 index 00000000..a57e9591 --- /dev/null +++ b/widget/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Play your podcasts\' episodes + No episode is currently playing.. + Invalid size, Resize your widget + \ No newline at end of file diff --git a/widget/src/main/res/xml/podcaster_widget_info.xml b/widget/src/main/res/xml/podcaster_widget_info.xml new file mode 100644 index 00000000..75dc8516 --- /dev/null +++ b/widget/src/main/res/xml/podcaster_widget_info.xml @@ -0,0 +1,16 @@ + + \ No newline at end of file