feat: add newsfeed widget (#384)#1018
feat: add newsfeed widget (#384)#1018MatthewTighe wants to merge 4 commits intospacecowboy:masterfrom
Conversation
13f3611 to
240a2b1
Compare
There was a problem hiding this comment.
Thank you so much for doing this. Apologies for the delay in review, I've been busy IRL.
When trying this I got the following error messages spamming my log and the widget wouldn't show anything:
Error in Glance App Widget
java.lang.IllegalArgumentException: RemoteViews for widget update exceeds maximum bitmap memory usage (used: 22240000, max: 15552000)
Once I commented out the bitmap images of the items it displayed.
So I've added some suggestions to make bitmaps optional to start with. Obviously it wont' fix the crash I saw but at least items without images will be rendered now.
I think overall it's a great start.
Before merging I think it's important to ensure all items are displayed. If it's easier, just disable images to start with maybe? Up to you.
And it needs to be resizable, because I think that is an overall guideline requirement by Google these days?
| @OptIn(ExperimentalCoroutinesApi::class) | ||
| fun getCurrentWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | ||
| combine( | ||
| currentWidgetFeedAndTag, | ||
| feedListFilter, | ||
| search, | ||
| ) { feedAndTag, feedListFilter, search -> | ||
| val (feedId, tag) = feedAndTag | ||
| FeedListArgs( | ||
| feedId = feedId, | ||
| tag = tag, | ||
| minReadTime = Instant.EPOCH, | ||
| newestFirst = true, | ||
| filter = feedListFilter, | ||
| search = search, | ||
| ) | ||
| }.flatMapLatest { | ||
| feedItemStore.getPagedFeedItemsRaw( | ||
| feedId = it.feedId, | ||
| tag = it.tag, | ||
| minReadTime = it.minReadTime, | ||
| newestFirst = it.newestFirst, | ||
| filter = it.filter, | ||
| search = it.search, | ||
| ) | ||
| } | ||
|
|
||
| @OptIn(ExperimentalCoroutinesApi::class) | ||
| fun getWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | ||
| combine( | ||
| currentFeedAndTag, | ||
| minReadTime, | ||
| currentSorting, | ||
| feedListFilter, | ||
| search, | ||
| ) { feedAndTag, minReadTime, currentSorting, feedListFilter, search -> | ||
| val (feedId, tag) = feedAndTag | ||
| FeedListArgs( | ||
| feedId = feedId, | ||
| tag = tag, | ||
| minReadTime = | ||
| when (feedId) { | ||
| ID_SAVED_ARTICLES -> Instant.EPOCH | ||
| else -> minReadTime | ||
| }, | ||
| newestFirst = currentSorting == SortingOptions.NEWEST_FIRST, | ||
| filter = feedListFilter, | ||
| search = search, | ||
| ) | ||
| }.flatMapLatest { | ||
| feedItemStore.getPagedFeedItemsRaw( | ||
| feedId = it.feedId, | ||
| tag = it.tag, | ||
| minReadTime = it.minReadTime, | ||
| newestFirst = it.newestFirst, | ||
| filter = it.filter, | ||
| search = it.search, | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
Correct me if I'm wrong, but there is no search functionality for the widget right?
So no need to include the search query?
| @OptIn(ExperimentalCoroutinesApi::class) | |
| fun getCurrentWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | |
| combine( | |
| currentWidgetFeedAndTag, | |
| feedListFilter, | |
| search, | |
| ) { feedAndTag, feedListFilter, search -> | |
| val (feedId, tag) = feedAndTag | |
| FeedListArgs( | |
| feedId = feedId, | |
| tag = tag, | |
| minReadTime = Instant.EPOCH, | |
| newestFirst = true, | |
| filter = feedListFilter, | |
| search = search, | |
| ) | |
| }.flatMapLatest { | |
| feedItemStore.getPagedFeedItemsRaw( | |
| feedId = it.feedId, | |
| tag = it.tag, | |
| minReadTime = it.minReadTime, | |
| newestFirst = it.newestFirst, | |
| filter = it.filter, | |
| search = it.search, | |
| ) | |
| } | |
| @OptIn(ExperimentalCoroutinesApi::class) | |
| fun getWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | |
| combine( | |
| currentFeedAndTag, | |
| minReadTime, | |
| currentSorting, | |
| feedListFilter, | |
| search, | |
| ) { feedAndTag, minReadTime, currentSorting, feedListFilter, search -> | |
| val (feedId, tag) = feedAndTag | |
| FeedListArgs( | |
| feedId = feedId, | |
| tag = tag, | |
| minReadTime = | |
| when (feedId) { | |
| ID_SAVED_ARTICLES -> Instant.EPOCH | |
| else -> minReadTime | |
| }, | |
| newestFirst = currentSorting == SortingOptions.NEWEST_FIRST, | |
| filter = feedListFilter, | |
| search = search, | |
| ) | |
| }.flatMapLatest { | |
| feedItemStore.getPagedFeedItemsRaw( | |
| feedId = it.feedId, | |
| tag = it.tag, | |
| minReadTime = it.minReadTime, | |
| newestFirst = it.newestFirst, | |
| filter = it.filter, | |
| search = it.search, | |
| ) | |
| } | |
| @OptIn(ExperimentalCoroutinesApi::class) | |
| fun getCurrentWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | |
| combine( | |
| currentWidgetFeedAndTag, | |
| feedListFilter, | |
| ) { feedAndTag, feedListFilter -> | |
| val (feedId, tag) = feedAndTag | |
| FeedListArgs( | |
| feedId = feedId, | |
| tag = tag, | |
| minReadTime = Instant.EPOCH, | |
| newestFirst = true, | |
| filter = feedListFilter, | |
| search = "", | |
| ) | |
| }.flatMapLatest { | |
| feedItemStore.getPagedFeedItemsRaw( | |
| feedId = it.feedId, | |
| tag = it.tag, | |
| minReadTime = it.minReadTime, | |
| newestFirst = it.newestFirst, | |
| filter = it.filter, | |
| search = it.search, | |
| ) | |
| } | |
| @OptIn(ExperimentalCoroutinesApi::class) | |
| fun getWidgetFeedListItems(): Flow<PagingData<FeedListItem>> = | |
| combine( | |
| currentFeedAndTag, | |
| minReadTime, | |
| currentSorting, | |
| feedListFilter, | |
| ) { feedAndTag, minReadTime, currentSorting, feedListFilter -> | |
| val (feedId, tag) = feedAndTag | |
| FeedListArgs( | |
| feedId = feedId, | |
| tag = tag, | |
| minReadTime = | |
| when (feedId) { | |
| ID_SAVED_ARTICLES -> Instant.EPOCH | |
| else -> minReadTime | |
| }, | |
| newestFirst = currentSorting == SortingOptions.NEWEST_FIRST, | |
| filter = feedListFilter, | |
| search = "", | |
| ) | |
| }.flatMapLatest { | |
| feedItemStore.getPagedFeedItemsRaw( | |
| feedId = it.feedId, | |
| tag = it.tag, | |
| minReadTime = it.minReadTime, | |
| newestFirst = it.newestFirst, | |
| filter = it.filter, | |
| search = it.search, | |
| ) | |
| } | |
| data class FeedWidgetItem( | ||
| val id: Long, | ||
| val title: String, | ||
| val snippet: String, | ||
| val feedTitle: String, | ||
| val unread: Boolean, | ||
| val pubDate: String, | ||
| val image: Bitmap, | ||
| val link: String?, | ||
| val bookmarked: Boolean, | ||
| val feedImageUrl: URL?, | ||
| val primarySortTime: Instant, | ||
| val rawPubDate: ZonedDateTime?, | ||
| val wordCount: Int, | ||
| ) | ||
|
|
||
| private fun FeedListItem.toWidgetItem(bitmap: Bitmap) = | ||
| FeedWidgetItem( | ||
| id, | ||
| title, | ||
| snippet, | ||
| feedTitle, | ||
| unread, | ||
| pubDate, | ||
| bitmap, | ||
| link, | ||
| bookmarked, | ||
| feedImageUrl, | ||
| primarySortTime, | ||
| rawPubDate, | ||
| wordCount, | ||
| ) | ||
|
|
||
| sealed class WidgetState { | ||
| data object Syncing : WidgetState() | ||
|
|
||
| data class Ready( | ||
| val items: PagingData<FeedWidgetItem>, | ||
| ) : WidgetState() | ||
| } | ||
|
|
||
| object FeederWidgetGlanceColorScheme { | ||
| val colors = | ||
| ColorProviders( | ||
| light = lightColorScheme(), | ||
| dark = darkColorScheme(), | ||
| ) | ||
| } | ||
|
|
||
| private class WidgetStateDataStore( | ||
| private val context: Context, | ||
| ) : DataStore<WidgetState> { | ||
| private val app = (context.applicationContext as FeederApplication) | ||
| private val imageLoader = app.imageLoader | ||
| private val loadImageBitmap: suspend (String) -> Bitmap? = { url -> | ||
| val request = | ||
| ImageRequest | ||
| .Builder(context) | ||
| .data(url) | ||
| .scale(Scale.FIT) | ||
| .precision(Precision.INEXACT) | ||
| .allowHardware(false) | ||
| .build() | ||
|
|
||
| (imageLoader.execute(request) as? SuccessResult)?.image?.toBitmap(200, 200) | ||
| } | ||
|
|
||
| private val repository: Repository by app.di.instance() | ||
|
|
||
| override val data: Flow<WidgetState> | ||
| get() = | ||
| combine( | ||
| repository.syncWorkerRunning, | ||
| repository | ||
| .getCurrentWidgetFeedListItems() | ||
| .map { pagingData -> | ||
| pagingData | ||
| .map { listItem -> | ||
| val bitmap = loadImageBitmap(listItem.feedImageUrl?.toString() ?: "") | ||
| Pair(listItem, bitmap) | ||
| }.filter { it.second != null } | ||
| .map { | ||
| it.first.toWidgetItem(it.second!!) | ||
| } | ||
| }, | ||
| ) { syncWorkerRunning, feedWidgetItems -> | ||
| if (syncWorkerRunning) { | ||
| WidgetState.Syncing | ||
| } else { | ||
| WidgetState.Ready(feedWidgetItems) | ||
| } | ||
| } | ||
|
|
||
| override suspend fun updateData(transform: suspend (t: WidgetState) -> WidgetState): WidgetState = throw NotImplementedError("Widget does not need to update its own data") | ||
| } | ||
|
|
||
| class FeedWidget : GlanceAppWidget() { | ||
| override val stateDefinition: GlanceStateDefinition<WidgetState> | ||
| get() = | ||
| object : GlanceStateDefinition<WidgetState> { | ||
| override suspend fun getDataStore( | ||
| context: Context, | ||
| fileKey: String, | ||
| ): DataStore<WidgetState> = WidgetStateDataStore(context) | ||
|
|
||
| override fun getLocation( | ||
| context: Context, | ||
| fileKey: String, | ||
| ): File = throw NotImplementedError("Widget does not provide a concrete state file location") | ||
| } | ||
|
|
||
| override suspend fun provideGlance( | ||
| context: Context, | ||
| id: GlanceId, | ||
| ) { | ||
| provideContent { | ||
| val app = (context.applicationContext as FeederApplication) | ||
| val coroutineScope = rememberCoroutineScope() | ||
| val runSync = { | ||
| val rssLocalSync by app.di.instance<RssLocalSync>() | ||
| coroutineScope.launch { rssLocalSync.syncFeeds() } | ||
| Unit | ||
| } | ||
| GlanceTheme(colors = FeederWidgetGlanceColorScheme.colors) { | ||
| WidgetContent( | ||
| widgetState = currentState(), | ||
| runSync = runSync, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun WidgetContent( | ||
| widgetState: WidgetState, | ||
| runSync: () -> Unit, | ||
| ) { | ||
| Column( | ||
| modifier = | ||
| GlanceModifier | ||
| .fillMaxHeight() | ||
| .background(GlanceTheme.colors.background) | ||
| .padding(4.dp), | ||
| ) { | ||
| FeederTitleBar(runSync) | ||
| when (widgetState) { | ||
| is WidgetState.Syncing -> WidgetSyncingContent() | ||
| is WidgetState.Ready -> WidgetReadyContent(widgetState.items) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun FeederTitleBar(runSync: () -> Unit) { | ||
| TitleBar( | ||
| startIcon = ImageProvider(R.drawable.ic_stat_f), | ||
| title = LocalContext.current.getString(R.string.widget_title), | ||
| modifier = GlanceModifier.clickable(onClick = actionStartActivity(MainActivity::class.java)), | ||
| actions = { | ||
| Image( | ||
| provider = ImageProvider(R.drawable.ic_stat_sync), | ||
| contentDescription = null, | ||
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | ||
| modifier = GlanceModifier.clickable(runSync), | ||
| ) | ||
|
|
||
| Spacer(modifier = GlanceModifier.width(8.dp)) | ||
|
|
||
| Image( | ||
| provider = ImageProvider(R.drawable.ic_settings), | ||
| contentDescription = "", | ||
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | ||
| modifier = GlanceModifier.clickable(actionStartActivity(FeedWidgetSettingsActivity::class.java)), | ||
| ) | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| @Composable | ||
| private fun WidgetSyncingContent() { | ||
| Box(contentAlignment = Alignment.Center) { | ||
| Text( | ||
| text = LocalContext.current.getString(R.string.widget_refreshing), | ||
| style = TextStyle(color = GlanceTheme.colors.onSurface), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun WidgetReadyContent(data: PagingData<FeedWidgetItem>) { | ||
| val items = flowOf(data).collectAsLazyPagingItems() | ||
| LazyColumn(modifier = GlanceModifier.padding(start = 12.dp)) { | ||
| items( | ||
| count = items.itemCount, | ||
| itemId = { items[it]?.id ?: 0 }, | ||
| ) { index -> | ||
| items[index]?.let { | ||
| WidgetCard(it) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun WidgetCard(item: FeedWidgetItem) { | ||
| Row(verticalAlignment = Alignment.CenterVertically) { | ||
| Image(ImageProvider(item.image), contentDescription = null) | ||
| Column { | ||
| Text( | ||
| text = item.title, | ||
| maxLines = 1, | ||
| style = | ||
| TextStyle( | ||
| fontWeight = FontWeight.Bold, | ||
| fontSize = 18.sp, | ||
| color = GlanceTheme.colors.onSurface, | ||
| ), | ||
| ) | ||
| Text( | ||
| text = item.snippet, | ||
| maxLines = 1, | ||
| style = | ||
| TextStyle( | ||
| fontWeight = FontWeight.Normal, | ||
| fontSize = 12.sp, | ||
| color = GlanceTheme.colors.secondary, | ||
| ), | ||
| ) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
| data class FeedWidgetItem( | |
| val id: Long, | |
| val title: String, | |
| val snippet: String, | |
| val feedTitle: String, | |
| val unread: Boolean, | |
| val pubDate: String, | |
| val image: Bitmap, | |
| val link: String?, | |
| val bookmarked: Boolean, | |
| val feedImageUrl: URL?, | |
| val primarySortTime: Instant, | |
| val rawPubDate: ZonedDateTime?, | |
| val wordCount: Int, | |
| ) | |
| private fun FeedListItem.toWidgetItem(bitmap: Bitmap) = | |
| FeedWidgetItem( | |
| id, | |
| title, | |
| snippet, | |
| feedTitle, | |
| unread, | |
| pubDate, | |
| bitmap, | |
| link, | |
| bookmarked, | |
| feedImageUrl, | |
| primarySortTime, | |
| rawPubDate, | |
| wordCount, | |
| ) | |
| sealed class WidgetState { | |
| data object Syncing : WidgetState() | |
| data class Ready( | |
| val items: PagingData<FeedWidgetItem>, | |
| ) : WidgetState() | |
| } | |
| object FeederWidgetGlanceColorScheme { | |
| val colors = | |
| ColorProviders( | |
| light = lightColorScheme(), | |
| dark = darkColorScheme(), | |
| ) | |
| } | |
| private class WidgetStateDataStore( | |
| private val context: Context, | |
| ) : DataStore<WidgetState> { | |
| private val app = (context.applicationContext as FeederApplication) | |
| private val imageLoader = app.imageLoader | |
| private val loadImageBitmap: suspend (String) -> Bitmap? = { url -> | |
| val request = | |
| ImageRequest | |
| .Builder(context) | |
| .data(url) | |
| .scale(Scale.FIT) | |
| .precision(Precision.INEXACT) | |
| .allowHardware(false) | |
| .build() | |
| (imageLoader.execute(request) as? SuccessResult)?.image?.toBitmap(200, 200) | |
| } | |
| private val repository: Repository by app.di.instance() | |
| override val data: Flow<WidgetState> | |
| get() = | |
| combine( | |
| repository.syncWorkerRunning, | |
| repository | |
| .getCurrentWidgetFeedListItems() | |
| .map { pagingData -> | |
| pagingData | |
| .map { listItem -> | |
| val bitmap = loadImageBitmap(listItem.feedImageUrl?.toString() ?: "") | |
| Pair(listItem, bitmap) | |
| }.filter { it.second != null } | |
| .map { | |
| it.first.toWidgetItem(it.second!!) | |
| } | |
| }, | |
| ) { syncWorkerRunning, feedWidgetItems -> | |
| if (syncWorkerRunning) { | |
| WidgetState.Syncing | |
| } else { | |
| WidgetState.Ready(feedWidgetItems) | |
| } | |
| } | |
| override suspend fun updateData(transform: suspend (t: WidgetState) -> WidgetState): WidgetState = throw NotImplementedError("Widget does not need to update its own data") | |
| } | |
| class FeedWidget : GlanceAppWidget() { | |
| override val stateDefinition: GlanceStateDefinition<WidgetState> | |
| get() = | |
| object : GlanceStateDefinition<WidgetState> { | |
| override suspend fun getDataStore( | |
| context: Context, | |
| fileKey: String, | |
| ): DataStore<WidgetState> = WidgetStateDataStore(context) | |
| override fun getLocation( | |
| context: Context, | |
| fileKey: String, | |
| ): File = throw NotImplementedError("Widget does not provide a concrete state file location") | |
| } | |
| override suspend fun provideGlance( | |
| context: Context, | |
| id: GlanceId, | |
| ) { | |
| provideContent { | |
| val app = (context.applicationContext as FeederApplication) | |
| val coroutineScope = rememberCoroutineScope() | |
| val runSync = { | |
| val rssLocalSync by app.di.instance<RssLocalSync>() | |
| coroutineScope.launch { rssLocalSync.syncFeeds() } | |
| Unit | |
| } | |
| GlanceTheme(colors = FeederWidgetGlanceColorScheme.colors) { | |
| WidgetContent( | |
| widgetState = currentState(), | |
| runSync = runSync, | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun WidgetContent( | |
| widgetState: WidgetState, | |
| runSync: () -> Unit, | |
| ) { | |
| Column( | |
| modifier = | |
| GlanceModifier | |
| .fillMaxHeight() | |
| .background(GlanceTheme.colors.background) | |
| .padding(4.dp), | |
| ) { | |
| FeederTitleBar(runSync) | |
| when (widgetState) { | |
| is WidgetState.Syncing -> WidgetSyncingContent() | |
| is WidgetState.Ready -> WidgetReadyContent(widgetState.items) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun FeederTitleBar(runSync: () -> Unit) { | |
| TitleBar( | |
| startIcon = ImageProvider(R.drawable.ic_stat_f), | |
| title = LocalContext.current.getString(R.string.widget_title), | |
| modifier = GlanceModifier.clickable(onClick = actionStartActivity(MainActivity::class.java)), | |
| actions = { | |
| Image( | |
| provider = ImageProvider(R.drawable.ic_stat_sync), | |
| contentDescription = null, | |
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | |
| modifier = GlanceModifier.clickable(runSync), | |
| ) | |
| Spacer(modifier = GlanceModifier.width(8.dp)) | |
| Image( | |
| provider = ImageProvider(R.drawable.ic_settings), | |
| contentDescription = "", | |
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | |
| modifier = GlanceModifier.clickable(actionStartActivity(FeedWidgetSettingsActivity::class.java)), | |
| ) | |
| }, | |
| ) | |
| } | |
| @Composable | |
| private fun WidgetSyncingContent() { | |
| Box(contentAlignment = Alignment.Center) { | |
| Text( | |
| text = LocalContext.current.getString(R.string.widget_refreshing), | |
| style = TextStyle(color = GlanceTheme.colors.onSurface), | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun WidgetReadyContent(data: PagingData<FeedWidgetItem>) { | |
| val items = flowOf(data).collectAsLazyPagingItems() | |
| LazyColumn(modifier = GlanceModifier.padding(start = 12.dp)) { | |
| items( | |
| count = items.itemCount, | |
| itemId = { items[it]?.id ?: 0 }, | |
| ) { index -> | |
| items[index]?.let { | |
| WidgetCard(it) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun WidgetCard(item: FeedWidgetItem) { | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Image(ImageProvider(item.image), contentDescription = null) | |
| Column { | |
| Text( | |
| text = item.title, | |
| maxLines = 1, | |
| style = | |
| TextStyle( | |
| fontWeight = FontWeight.Bold, | |
| fontSize = 18.sp, | |
| color = GlanceTheme.colors.onSurface, | |
| ), | |
| ) | |
| Text( | |
| text = item.snippet, | |
| maxLines = 1, | |
| style = | |
| TextStyle( | |
| fontWeight = FontWeight.Normal, | |
| fontSize = 12.sp, | |
| color = GlanceTheme.colors.secondary, | |
| ), | |
| ) | |
| } | |
| } | |
| } | |
| data class FeedWidgetItem( | |
| val id: Long, | |
| val title: String, | |
| val snippet: String, | |
| val feedTitle: String, | |
| val unread: Boolean, | |
| val pubDate: String, | |
| val image: Bitmap?, | |
| val link: String?, | |
| val bookmarked: Boolean, | |
| val feedImageUrl: URL?, | |
| val primarySortTime: Instant, | |
| val rawPubDate: ZonedDateTime?, | |
| val wordCount: Int, | |
| ) | |
| private fun FeedListItem.toWidgetItem(bitmap: Bitmap?) = | |
| FeedWidgetItem( | |
| id, | |
| title, | |
| snippet, | |
| feedTitle, | |
| unread, | |
| pubDate, | |
| bitmap, | |
| link, | |
| bookmarked, | |
| feedImageUrl, | |
| primarySortTime, | |
| rawPubDate, | |
| wordCount, | |
| ) | |
| sealed class WidgetState { | |
| data object Syncing : WidgetState() | |
| data class Ready( | |
| val items: PagingData<FeedWidgetItem>, | |
| ) : WidgetState() | |
| } | |
| object FeederWidgetGlanceColorScheme { | |
| val colors = | |
| ColorProviders( | |
| light = lightColorScheme(), | |
| dark = darkColorScheme(), | |
| ) | |
| } | |
| private class WidgetStateDataStore( | |
| private val context: Context, | |
| ) : DataStore<WidgetState> { | |
| private val app = (context.applicationContext as FeederApplication) | |
| private val imageLoader = app.imageLoader | |
| private val loadImageBitmap: suspend (String) -> Bitmap? = { url -> | |
| val request = | |
| ImageRequest | |
| .Builder(context) | |
| .data(url) | |
| .scale(Scale.FIT) | |
| .precision(Precision.INEXACT) | |
| .allowHardware(false) | |
| .build() | |
| (imageLoader.execute(request) as? SuccessResult)?.image?.toBitmap(200, 200) | |
| } | |
| private val repository: Repository by app.di.instance() | |
| override val data: Flow<WidgetState> | |
| get() = | |
| combine( | |
| repository.syncWorkerRunning, | |
| repository | |
| .getCurrentWidgetFeedListItems() | |
| .map { pagingData -> | |
| pagingData | |
| .map { listItem -> | |
| val bitmap = loadImageBitmap(listItem.feedImageUrl?.toString() ?: "") | |
| Pair(listItem, bitmap) | |
| } | |
| .map { | |
| it.first.toWidgetItem(it.second) | |
| } | |
| }, | |
| ) { syncWorkerRunning, feedWidgetItems -> | |
| if (syncWorkerRunning) { | |
| WidgetState.Syncing | |
| } else { | |
| WidgetState.Ready(feedWidgetItems) | |
| } | |
| } | |
| override suspend fun updateData(transform: suspend (t: WidgetState) -> WidgetState): WidgetState = throw NotImplementedError("Widget does not need to update its own data") | |
| } | |
| class FeedWidget : GlanceAppWidget() { | |
| override val stateDefinition: GlanceStateDefinition<WidgetState> | |
| get() = | |
| object : GlanceStateDefinition<WidgetState> { | |
| override suspend fun getDataStore( | |
| context: Context, | |
| fileKey: String, | |
| ): DataStore<WidgetState> = WidgetStateDataStore(context) | |
| override fun getLocation( | |
| context: Context, | |
| fileKey: String, | |
| ): File = throw NotImplementedError("Widget does not provide a concrete state file location") | |
| } | |
| override suspend fun provideGlance( | |
| context: Context, | |
| id: GlanceId, | |
| ) { | |
| provideContent { | |
| val app = (context.applicationContext as FeederApplication) | |
| val coroutineScope = rememberCoroutineScope() | |
| val runSync = { | |
| val rssLocalSync by app.di.instance<RssLocalSync>() | |
| coroutineScope.launch { rssLocalSync.syncFeeds() } | |
| Unit | |
| } | |
| GlanceTheme(colors = FeederWidgetGlanceColorScheme.colors) { | |
| WidgetContent( | |
| widgetState = currentState(), | |
| runSync = runSync, | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun WidgetContent( | |
| widgetState: WidgetState, | |
| runSync: () -> Unit, | |
| ) { | |
| Column( | |
| modifier = | |
| GlanceModifier | |
| .fillMaxHeight() | |
| .background(GlanceTheme.colors.background) | |
| .padding(4.dp), | |
| ) { | |
| FeederTitleBar(runSync) | |
| when (widgetState) { | |
| is WidgetState.Syncing -> WidgetSyncingContent() | |
| is WidgetState.Ready -> WidgetReadyContent(widgetState.items) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun FeederTitleBar(runSync: () -> Unit) { | |
| TitleBar( | |
| startIcon = ImageProvider(R.drawable.ic_stat_f), | |
| title = LocalContext.current.getString(R.string.widget_title), | |
| modifier = GlanceModifier.clickable(onClick = actionStartActivity(MainActivity::class.java)), | |
| actions = { | |
| Image( | |
| provider = ImageProvider(R.drawable.ic_stat_sync), | |
| contentDescription = null, | |
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | |
| modifier = GlanceModifier.clickable(runSync), | |
| ) | |
| Spacer(modifier = GlanceModifier.width(8.dp)) | |
| Image( | |
| provider = ImageProvider(R.drawable.ic_settings), | |
| contentDescription = "", | |
| colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface), | |
| modifier = GlanceModifier.clickable(actionStartActivity(FeedWidgetSettingsActivity::class.java)), | |
| ) | |
| }, | |
| ) | |
| } | |
| @Composable | |
| private fun WidgetSyncingContent() { | |
| Box(contentAlignment = Alignment.Center) { | |
| Text( | |
| text = LocalContext.current.getString(R.string.widget_refreshing), | |
| style = TextStyle(color = GlanceTheme.colors.onSurface), | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun WidgetReadyContent(data: PagingData<FeedWidgetItem>) { | |
| val items = flowOf(data).collectAsLazyPagingItems() | |
| LazyColumn(modifier = GlanceModifier.padding(start = 12.dp)) { | |
| items( | |
| count = items.itemCount, | |
| itemId = { items[it]?.id ?: 0 }, | |
| ) { index -> | |
| items[index]?.let { | |
| WidgetCard(it) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun WidgetCard(item: FeedWidgetItem) { | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| item.image?.let { | |
| Image(ImageProvider(item.image), contentDescription = null) | |
| } | |
| Column { | |
| Text( | |
| text = item.title, | |
| maxLines = 1, | |
| style = | |
| TextStyle( | |
| fontWeight = FontWeight.Bold, | |
| fontSize = 18.sp, | |
| color = GlanceTheme.colors.onSurface, | |
| ), | |
| ) | |
| Text( | |
| text = item.snippet, | |
| maxLines = 1, | |
| style = | |
| TextStyle( | |
| fontWeight = FontWeight.Normal, | |
| fontSize = 12.sp, | |
| color = GlanceTheme.colors.secondary, | |
| ), | |
| ) | |
| } | |
| } | |
| } |
It must be possible to display items without images
|
@spacecowboy thanks so much for taking a look! I've applied your patch feedback. For the memory crash, I've tried a couple things:
That said, I haven't run into the crash in my local testing. Could you either see if you can still reproduce, or perhaps pass along a repro OPML export? I've kept the patch applying your feedback separate for now, assuming we will squash and merge eventually |
This PR adds a newsfeed widget using Jetpack Glance for #384. It:
There are a couple areas that could potentially use more polish, but I wanted to get this up to at least start a conversation as to whether it is worth waiting for them.
Areas of potential improvement:
LocalContext, it would need to provided by something like Robolectric, which I did not think was worth importing for that alone. there's otherwise very little logic here, but happy to eventually add tests for the Repository etc if preferredLMK your thoughts on whether any of these are worth baking longer - happy to keep trucking on this but it's obviously been slow going. Assume we will just squash these commits as well?
Screen_recording_20260106_222913.webm