Skip to content

Commit 57913c5

Browse files
authored
Widget preview updates (#1541)
This PR * Adds a widget preview layout * Add a widget preview Image * Adds a generated widget preview * Cleans up some widget layout issues on denser homescreens and in landscape
2 parents 85e61a2 + 8cf60ea commit 57913c5

File tree

20 files changed

+414
-42
lines changed

20 files changed

+414
-42
lines changed

Jetcaster/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ limitations under the License.
134134
[epstore]: mobile/src/main/java/com/example/jetcaster/data/EpisodeStore.kt
135135
[catstore]: mobile/src/main/java/com/example/jetcaster/data/CategoryStore.kt
136136
[db]: mobile/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt
137+
[glance]: https://developer.android.com/develop/ui/compose/glance
137138
[homevm]: mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt
138139
[homeui]: mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt
139140
[compose]: https://developer.android.com/jetpack/compose

Jetcaster/glancewidget/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies {
4848
implementation(libs.coil.kt.compose)
4949

5050
implementation(libs.androidx.core.ktx)
51+
implementation(libs.android.material3)
5152
implementation(libs.androidx.lifecycle.runtime)
5253
implementation(libs.androidx.activity.compose)
5354
implementation(platform(libs.androidx.compose.bom))

Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt

Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import android.graphics.drawable.BitmapDrawable
2222
import android.graphics.drawable.Drawable
2323
import android.net.Uri
2424
import android.util.Log
25-
import androidx.compose.material3.darkColorScheme
26-
import androidx.compose.material3.lightColorScheme
2725
import androidx.compose.runtime.Composable
2826
import androidx.compose.runtime.LaunchedEffect
2927
import androidx.compose.runtime.getValue
@@ -32,6 +30,7 @@ import androidx.compose.runtime.remember
3230
import androidx.compose.runtime.rememberCoroutineScope
3331
import androidx.compose.runtime.setValue
3432
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.unit.Dp
3534
import androidx.compose.ui.unit.DpSize
3635
import androidx.compose.ui.unit.dp
3736
import androidx.compose.ui.unit.sp
@@ -59,7 +58,6 @@ import androidx.glance.layout.Spacer
5958
import androidx.glance.layout.fillMaxSize
6059
import androidx.glance.layout.padding
6160
import androidx.glance.layout.size
62-
import androidx.glance.material3.ColorProviders
6361
import androidx.glance.text.FontWeight
6462
import androidx.glance.text.Text
6563
import androidx.glance.text.TextStyle
@@ -70,7 +68,7 @@ import coil.request.ImageRequest
7068
import kotlinx.coroutines.Dispatchers
7169
import kotlinx.coroutines.launch
7270

73-
internal val TAG = "JetcasterAppWidegt"
71+
internal val TAG = "JetcasterAppWidget"
7472

7573
/**
7674
* Implementation of App Widget functionality.
@@ -85,28 +83,32 @@ data class JetcasterAppWidgetViewState(
8583
val podcastTitle: String,
8684
val isPlaying: Boolean,
8785
val albumArtUri: String,
88-
val useDynamicColor: Boolean
8986
)
9087

9188
private object Sizes {
89+
val short = 72.dp
9290
val minWidth = 140.dp
9391
val smallBucketCutoffWidth = 250.dp // anything from minWidth to this will have no title
9492

95-
val imageNormal = 80.dp
96-
val imageCondensed = 60.dp
93+
val normal = 80.dp
94+
val medium = 56.dp
95+
val condensed = 48.dp
9796
}
9897

99-
private enum class SizeBucket { Invalid, Narrow, Normal }
98+
private enum class SizeBucket { Invalid, Narrow, Normal, NarrowShort, NormalShort }
10099

101100
@Composable
102101
private fun calculateSizeBucket(): SizeBucket {
103102
val size: DpSize = LocalSize.current
104103
val width = size.width
104+
val height = size.height
105105

106106
return when {
107107
width < Sizes.minWidth -> SizeBucket.Invalid
108-
width <= Sizes.smallBucketCutoffWidth -> SizeBucket.Narrow
109-
else -> SizeBucket.Normal
108+
width <= Sizes.smallBucketCutoffWidth ->
109+
if (height >= Sizes.short) SizeBucket.Narrow else SizeBucket.NarrowShort
110+
else ->
111+
if (height >= Sizes.short) SizeBucket.Normal else SizeBucket.NormalShort
110112
}
111113
}
112114

@@ -122,29 +124,39 @@ class JetcasterAppWidget : GlanceAppWidget() {
122124
podcastTitle = "Now in Android",
123125
isPlaying = false,
124126
albumArtUri = "https://static.libsyn.com/p/assets/9/f/f/3/" +
125-
"9ff3cb5dc6cfb3e2e5bbc093207a2619/NIA000_PodcastThumbnail.png",
126-
useDynamicColor = false
127+
"9ff3cb5dc6cfb3e2e5bbc093207a2619/NIA000_PodcastThumbnail.png"
127128
)
128129

129130
provideContent {
130131
val sizeBucket = calculateSizeBucket()
131132
val playPauseIcon = if (testState.isPlaying) PlayPauseIcon.Pause else PlayPauseIcon.Play
132133
val artUri = Uri.parse(testState.albumArtUri)
133134

134-
GlanceTheme(
135-
colors = ColorProviders(
136-
light = lightColorScheme(),
137-
dark = darkColorScheme()
138-
)
139-
) {
135+
GlanceTheme {
140136
when (sizeBucket) {
141137
SizeBucket.Invalid -> WidgetUiInvalidSize()
142-
SizeBucket.Narrow -> WidgetUiNarrow(
138+
SizeBucket.Narrow -> Widget(
139+
iconSize = Sizes.medium,
143140
imageUri = artUri,
144141
playPauseIcon = playPauseIcon
145142
)
146143

147144
SizeBucket.Normal -> WidgetUiNormal(
145+
iconSize = Sizes.normal,
146+
title = testState.episodeTitle,
147+
subtitle = testState.podcastTitle,
148+
imageUri = artUri,
149+
playPauseIcon = playPauseIcon
150+
)
151+
152+
SizeBucket.NarrowShort -> Widget(
153+
iconSize = Sizes.condensed,
154+
imageUri = artUri,
155+
playPauseIcon = playPauseIcon
156+
)
157+
158+
SizeBucket.NormalShort -> WidgetUiNormal(
159+
iconSize = Sizes.condensed,
148160
title = testState.episodeTitle,
149161
subtitle = testState.podcastTitle,
150162
imageUri = artUri,
@@ -162,20 +174,23 @@ private fun WidgetUiNormal(
162174
subtitle: String,
163175
imageUri: Uri,
164176
playPauseIcon: PlayPauseIcon,
177+
iconSize: Dp,
165178
) {
166-
Scaffold(titleBar = {} /* title bar will be optional starting in glance 1.1.0-beta3*/) {
179+
180+
Scaffold {
167181
Row(
168182
GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Vertical.CenterVertically
169183
) {
170-
AlbumArt(imageUri, GlanceModifier.size(Sizes.imageNormal))
184+
AlbumArt(imageUri, GlanceModifier.size(iconSize))
171185
PodcastText(title, subtitle, modifier = GlanceModifier.padding(16.dp).defaultWeight())
172-
PlayPauseButton(playPauseIcon, {})
186+
PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {})
173187
}
174188
}
175189
}
176190

177191
@Composable
178-
private fun WidgetUiNarrow(
192+
private fun Widget(
193+
iconSize: Dp,
179194
imageUri: Uri,
180195
playPauseIcon: PlayPauseIcon,
181196
) {
@@ -184,9 +199,9 @@ private fun WidgetUiNarrow(
184199
modifier = GlanceModifier.fillMaxSize(),
185200
verticalAlignment = Alignment.Vertical.CenterVertically
186201
) {
187-
AlbumArt(imageUri, GlanceModifier.size(Sizes.imageCondensed))
202+
AlbumArt(imageUri, GlanceModifier.size(iconSize))
188203
Spacer(GlanceModifier.defaultWeight())
189-
PlayPauseButton(playPauseIcon, {})
204+
PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {})
190205
}
191206
}
192207
}
@@ -209,22 +224,44 @@ private fun AlbumArt(
209224
@Composable
210225
fun PodcastText(title: String, subtitle: String, modifier: GlanceModifier = GlanceModifier) {
211226
val fgColor = GlanceTheme.colors.onPrimaryContainer
212-
Column(modifier) {
213-
Text(
214-
text = title,
215-
style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = fgColor),
216-
maxLines = 2,
217-
)
218-
Text(
219-
text = subtitle,
220-
style = TextStyle(fontSize = 14.sp, color = fgColor),
221-
maxLines = 2,
222-
)
227+
val size = LocalSize.current
228+
when {
229+
size.height >= Sizes.short -> Column(modifier) {
230+
Text(
231+
text = title,
232+
style = TextStyle(
233+
fontSize = 16.sp,
234+
fontWeight = FontWeight.Medium,
235+
color = fgColor
236+
),
237+
maxLines = 2,
238+
)
239+
Text(
240+
text = subtitle,
241+
style = TextStyle(fontSize = 14.sp, color = fgColor),
242+
maxLines = 2,
243+
)
244+
}
245+
else -> Column(modifier) {
246+
Text(
247+
text = title,
248+
style = TextStyle(
249+
fontSize = 12.sp,
250+
fontWeight = FontWeight.Medium,
251+
color = fgColor
252+
),
253+
maxLines = 1,
254+
)
255+
}
223256
}
224257
}
225258

226259
@Composable
227-
private fun PlayPauseButton(state: PlayPauseIcon, onClick: () -> Unit) {
260+
private fun PlayPauseButton(
261+
modifier: GlanceModifier = GlanceModifier.size(Sizes.normal),
262+
state: PlayPauseIcon,
263+
onClick: () -> Unit
264+
) {
228265
val (iconRes: Int, description: Int) = when (state) {
229266
PlayPauseIcon.Play -> R.drawable.outline_play_arrow_24 to R.string.content_description_play
230267
PlayPauseIcon.Pause -> R.drawable.outline_pause_24 to R.string.content_description_pause
@@ -234,7 +271,8 @@ private fun PlayPauseButton(state: PlayPauseIcon, onClick: () -> Unit) {
234271
val contentDescription = LocalContext.current.getString(description)
235272

236273
SquareIconButton(
237-
provider,
274+
modifier = modifier,
275+
imageProvider = provider,
238276
contentDescription = contentDescription,
239277
onClick = onClick
240278
)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2024 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetcaster.glancewidget
18+
19+
import android.appwidget.AppWidgetManager
20+
import android.appwidget.AppWidgetProviderInfo
21+
import android.content.ComponentName
22+
import android.content.Context
23+
import android.os.Build
24+
import android.util.Log
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.ui.unit.DpSize
27+
import androidx.compose.ui.unit.dp
28+
import androidx.glance.GlanceId
29+
import androidx.glance.GlanceModifier
30+
import androidx.glance.GlanceTheme
31+
import androidx.glance.Image
32+
import androidx.glance.ImageProvider
33+
import androidx.glance.appwidget.GlanceAppWidget
34+
import androidx.glance.appwidget.SizeMode
35+
import androidx.glance.appwidget.components.Scaffold
36+
import androidx.glance.appwidget.components.SquareIconButton
37+
import androidx.glance.appwidget.compose
38+
import androidx.glance.appwidget.provideContent
39+
import androidx.glance.layout.Alignment
40+
import androidx.glance.layout.Row
41+
import androidx.glance.layout.Spacer
42+
import androidx.glance.layout.fillMaxSize
43+
import androidx.glance.layout.size
44+
import androidx.glance.layout.wrapContentSize
45+
import kotlinx.coroutines.CoroutineScope
46+
import kotlinx.coroutines.Dispatchers
47+
import kotlinx.coroutines.launch
48+
49+
private object SizesPreview {
50+
val medium = 56.dp
51+
}
52+
53+
/**
54+
* This is a convenience function for updating the widget preview using Generated Previews.
55+
*
56+
* In a real application, this would be called whenever the widget's state changes.
57+
*/
58+
fun updateWidgetPreview(context: Context) {
59+
60+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
61+
CoroutineScope(Dispatchers.IO).launch {
62+
try {
63+
val appwidgetManager = AppWidgetManager.getInstance(context)
64+
65+
appwidgetManager.setWidgetPreview(
66+
ComponentName(context, JetcasterAppWidgetReceiver::class.java),
67+
AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN,
68+
JetcasterAppWidgetPreview().compose(
69+
context,
70+
size = DpSize(160.dp, 64.dp)
71+
),
72+
)
73+
} catch (e: Exception) {
74+
Log.e(TAG, e.message, e)
75+
}
76+
}
77+
}
78+
}
79+
80+
class JetcasterAppWidgetPreview : GlanceAppWidget() {
81+
override val sizeMode: SizeMode
82+
get() = SizeMode.Exact
83+
84+
override suspend fun provideGlance(context: Context, id: GlanceId) {
85+
86+
provideContent {
87+
GlanceTheme {
88+
Widget()
89+
}
90+
}
91+
}
92+
}
93+
94+
@Composable
95+
private fun Widget() {
96+
97+
Scaffold {
98+
Row(
99+
modifier = GlanceModifier.fillMaxSize(),
100+
verticalAlignment = Alignment.Vertical.CenterVertically
101+
) {
102+
Image(
103+
modifier = GlanceModifier.wrapContentSize().size(SizesPreview.medium),
104+
provider = ImageProvider(R.drawable.widget_preview_thumbnail),
105+
contentDescription = ""
106+
)
107+
Spacer(GlanceModifier.defaultWeight())
108+
SquareIconButton(
109+
modifier = GlanceModifier.size(SizesPreview.medium),
110+
imageProvider = ImageProvider(R.drawable.outline_play_arrow_24),
111+
contentDescription = "",
112+
onClick = { }
113+
)
114+
}
115+
}
116+
}

Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
22

33
<path android:fillColor="@android:color/white" android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
44

21.8 KB
Loading
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (C) 2024 The Android Open Source Project
3+
~
4+
~ Licensed under the Apache License, Version 2.0 (the "License");
5+
~ you may not use this file except in compliance with the License.
6+
~ You may obtain a copy of the License at
7+
~
8+
~ http://www.apache.org/licenses/LICENSE-2.0
9+
~
10+
~ Unless required by applicable law or agreed to in writing, software
11+
~ distributed under the License is distributed on an "AS IS" BASIS,
12+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
~ See the License for the specific language governing permissions and
14+
~ limitations under the License.
15+
-->
16+
17+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
18+
android:shape="rectangle">
19+
<corners android:radius="16dp"/>
20+
</shape>
324 KB
Loading

0 commit comments

Comments
 (0)