diff --git a/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt b/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt index 58e70d895c0..e91cd0d4c89 100644 --- a/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt +++ b/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt @@ -1,5 +1,6 @@ package org.wikipedia.compose.components +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -44,7 +45,8 @@ fun HtmlText( overflow: TextOverflow = TextOverflow.Ellipsis, lineHeight: TextUnit = 1.6.em, linkInteractionListener: LinkInteractionListener = defaultLinkInteractionListener(), - textAlign: TextAlign = TextAlign.Start + textAlign: TextAlign = TextAlign.Start, + autoSize: TextAutoSize? = null ) { Text( modifier = modifier, @@ -58,7 +60,8 @@ fun HtmlText( color = color, maxLines = maxLines, overflow = overflow, - textAlign = textAlign + textAlign = textAlign, + autoSize = autoSize ) } diff --git a/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt b/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt index d2df818fb0c..ae6b57e2818 100644 --- a/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt @@ -13,7 +13,9 @@ import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.image.FeaturedImage import org.wikipedia.feed.news.NewsItem import org.wikipedia.feed.onthisday.OnThisDay +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceType import org.wikipedia.feed.topread.TopRead +import org.wikipedia.settings.Prefs import java.time.LocalDate enum class HomeTab { COMMUNITY, FOR_YOU } @@ -56,7 +58,9 @@ class HomeViewModel : ViewModel() { val wikiSite get() = WikipediaApp.instance.wikiSite - private val _selectedTab = MutableStateFlow(HomeTab.COMMUNITY) + private val _selectedTab = MutableStateFlow( + if (Prefs.exploreFeedPreferenceSelection == FeedPreferenceType.PERSONALIZED) HomeTab.FOR_YOU else HomeTab.COMMUNITY + ) val selectedTab = _selectedTab.asStateFlow() private val _communityState = MutableStateFlow(CommunityContentState()) @@ -88,7 +92,11 @@ class HomeViewModel : ViewModel() { } init { - loadCommunityContent() + if (_selectedTab.value == HomeTab.COMMUNITY) { + loadCommunityContent() + } else { + loadForYouContent() + } } fun refreshCommunityContent() { diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt index 907bd822cba..e9c60aaef01 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt @@ -14,7 +14,7 @@ import org.wikipedia.util.ImageUrlUtil @Serializable class NewsItem( val story: String = "", - val links: List = emptyList(), + val links: List = emptyList() ) : Parcelable { fun linkCards(wiki: WikiSite): List { diff --git a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt index 24abd79fa69..0184165f555 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.compose.components.PageIndicator import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceScreen import org.wikipedia.feed.personalization.interest.InterestOnboardingScreen // TODO: probably renaming the screen name @@ -46,6 +47,7 @@ fun PersonalizationScreen( ) { val coroutineScope = rememberCoroutineScope() val interestUiState = viewModel.interestUiState.collectAsState() + val feedPreferenceUiState = viewModel.feedPreferenceUiState.collectAsState() val pagerState = rememberPagerState(pageCount = { screens.size }) LaunchedEffect(pagerState.currentPage) { @@ -101,13 +103,23 @@ fun PersonalizationScreen( viewModel.deselectAllArticles() }, retryLoading = { - viewModel.retryLoading() + viewModel.retryInterestsLoading() }, showError = showError ) } PersonalizationPage.FEED_PREFERENCE -> { - // TODO: implement feed preference screen + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = feedPreferenceUiState.value.selectedType, + communityContentState = feedPreferenceUiState.value.communityState, + personalizedContentState = feedPreferenceUiState.value.personalizedState, + onTypeSelected = { viewModel.onFeedPreferenceTypeSelected(it) }, + onRetryClick = { viewModel.retryFeedPreferenceLoading(it) } + ) } } } diff --git a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt index 0af02a2b915..9d5562f70e3 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase +import org.wikipedia.feed.personalization.feedpreference.FeedContentState +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceContent +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceRepository +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceType +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceUiState import org.wikipedia.feed.personalization.interest.ArticlesState import org.wikipedia.feed.personalization.interest.InterestSelectionRepository import org.wikipedia.feed.personalization.interest.InterestUiState @@ -36,8 +41,15 @@ private data class PersonalizedViewModelState( val articlesLoading: Boolean = false, val articlesError: Throwable? = null, val selectedArticles: Set = emptySet(), - val selectedTopics: List = emptyList() + val selectedTopics: List = emptyList(), // Feed preference screen properties + val feedPreferenceType: FeedPreferenceType = FeedPreferenceType.COMMUNITY, + val communityContent: List = emptyList(), + val communityLoading: Boolean = false, + val communityError: Throwable? = null, + val personalizedContent: List = emptyList(), + val personalizedLoading: Boolean = false, + val personalizedError: Throwable? = null ) { fun toInterestUiState(): InterestUiState { return InterestUiState( @@ -64,12 +76,27 @@ private data class PersonalizedViewModelState( ) } - // Each screen in the personalization flow would have its own function - // fun toFeedPreferenceUiState(): FeedPreferenceUiState { ... } + fun toFeedPreferenceUiState(): FeedPreferenceUiState { + return FeedPreferenceUiState( + selectedType = feedPreferenceType, + communityState = when { + communityLoading -> FeedContentState.Loading + communityError != null -> FeedContentState.Error(communityError) + else -> FeedContentState.Success(communityContent) + }, + personalizedState = when { + personalizedLoading -> FeedContentState.Loading + personalizedError != null -> FeedContentState.Error(personalizedError) + personalizedContent.isEmpty() -> FeedContentState.Empty + else -> FeedContentState.Success(personalizedContent) + } + ) + } } class PersonalizationViewModel( - private val interestSelectionRepository: InterestSelectionRepository + private val interestSelectionRepository: InterestSelectionRepository, + private val feedPreferenceRepository: FeedPreferenceRepository ) : ViewModel() { // Single source of truth for all personalization state, can be easily extended to include feed preference and language selection states as well private val state = MutableStateFlow(PersonalizedViewModelState()) @@ -85,9 +112,18 @@ class PersonalizationViewModel( initialValue = state.value.toInterestUiState() ) + val feedPreferenceUiState = state + .map { it.toFeedPreferenceUiState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = state.value.toFeedPreferenceUiState() + ) + fun onPageChanged(screen: PersonalizationPage) { when (screen) { PersonalizationPage.INTERESTS -> loadInterestSelectionScreen() + PersonalizationPage.FEED_PREFERENCE -> loadFeedPreferenceScreen() else -> {} } } @@ -101,6 +137,38 @@ class PersonalizationViewModel( } } + private fun loadFeedPreferenceScreen() { + if (state.value.communityContent.isEmpty()) { + loadCommunityPreviewContent() + } + loadPersonalizedPreviewContent() + } + + private fun loadCommunityPreviewContent() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(communityLoading = false, communityError = throwable) } + L.e(throwable) + }) { + state.update { it.copy(communityLoading = true, communityError = null) } + val communityContent = feedPreferenceRepository.getCommunityContent() + state.update { it.copy(communityContent = communityContent, communityLoading = false) } + } + } + + private fun loadPersonalizedPreviewContent() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(personalizedLoading = false, personalizedError = throwable) } + L.e(throwable) + }) { + state.update { it.copy(personalizedLoading = true, personalizedError = null) } + val personalizedContent = feedPreferenceRepository.getInterests( + selectedTopics = state.value.selectedTopics, + selectedArticles = state.value.selectedArticles + ) + state.update { it.copy(personalizedContent = personalizedContent, personalizedLoading = false) } + } + } + private suspend fun loadTopics() { if (state.value.topics.isNotEmpty()) return @@ -281,7 +349,7 @@ class PersonalizationViewModel( } } - fun retryLoading() { + fun retryInterestsLoading() { val last = state.value.selectedTopics.lastOrNull() if (last != null) { loadArticlesByTopic(topic = last) @@ -290,6 +358,18 @@ class PersonalizationViewModel( } } + fun onFeedPreferenceTypeSelected(type: FeedPreferenceType) { + feedPreferenceRepository.saveFeedPreferenceSelection(type) + state.update { it.copy(feedPreferenceType = type) } + } + + fun retryFeedPreferenceLoading(type: FeedPreferenceType) { + when (type) { + FeedPreferenceType.COMMUNITY -> loadCommunityPreviewContent() + FeedPreferenceType.PERSONALIZED -> loadPersonalizedPreviewContent() + } + } + companion object { val Factory = viewModelFactory { initializer { @@ -300,6 +380,10 @@ class PersonalizationViewModel( historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), readingListPageDao = AppDatabase.instance.readingListPageDao(), wikiSite = WikipediaApp.instance.wikiSite + ), + feedPreferenceRepository = FeedPreferenceRepository( + historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), + wikiSite = WikipediaApp.instance.wikiSite ) ) } diff --git a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt new file mode 100644 index 00000000000..0115c82e98c --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -0,0 +1,120 @@ +package org.wikipedia.feed.personalization.feedpreference + +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.feed.personalization.interest.OnboardingTopic +import org.wikipedia.history.db.HistoryEntryWithImageDao +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import org.wikipedia.util.StringUtil +import java.time.LocalDate + +class FeedPreferenceRepository( + private val historyEntryWithImageDao: HistoryEntryWithImageDao, + private val wikiSite: WikiSite +) { + suspend fun getCommunityContent(): List { + val currentDate = LocalDate.now() + + val response = ServiceFactory.getRest(wikiSite).getFeedFeatured( + year = currentDate.year.toString(), + month = "%02d".format(currentDate.monthValue), + day = "%02d".format(currentDate.dayOfMonth), + lang = wikiSite.languageCode + ) + + val featuredArticle = response.tfa?.let { article -> + FeedPreferenceContent( + title = article.displayTitle, + description = article.description, + imageUrl = article.thumbnailUrl, + tag = "Featured Article" // TODO: use localized string resource + ) + } + + val pictureOfTheDay = response.potd?.let { potd -> + FeedPreferenceContent( + title = null, + description = potd.description.text, + imageUrl = potd.thumbnailUrl, + tag = "Picture of the Day" // TODO: use localized string resource + ) + } + + val inTheNewsArticle = response.news?.first().let { newsItem -> + FeedPreferenceContent( + title = null, + description = StringUtil.removeHTMLTags(newsItem?.story), + imageUrl = newsItem?.thumbUrl(), + tag = "In the News" // TODO: use localized string resource + ) + } + + return listOfNotNull( + featuredArticle, + pictureOfTheDay, + inTheNewsArticle + ) + } + + suspend fun getInterests( + selectedTopics: List, + selectedArticles: Set + ): List { + if (selectedTopics.isNotEmpty()) { + val contentSize = 3 + val topicsToUse = selectedTopics.takeLast(contentSize) + val baseLimit = contentSize / topicsToUse.size + val remainder = contentSize % topicsToUse.size + + val content = topicsToUse.flatMapIndexed { index, topic -> + val limit = if (index < remainder) baseLimit + 1 else baseLimit + val response = ServiceFactory.get(wikiSite).getArticlesByTopic(articleTopics = topic.queryTopicId, limit = limit) + response.query?.pages?.map { page -> + FeedPreferenceContent( + title = page.title, + description = page.description, + imageUrl = page.thumbUrl(), + tag = topic.displayTitle + ) + } ?: emptyList() + } + return content + } + + if (selectedArticles.isNotEmpty()) { + val moreLikeSearchTerm = "morelike:${selectedArticles.take(3).joinToString("|") { it.prefixedText }}" + val response = ServiceFactory.get(wikiSite).searchMoreLike(searchTerm = moreLikeSearchTerm, gsrLimit = 3, piLimit = 3) + val content = response.query?.pages?.map { page -> + FeedPreferenceContent( + title = page.title, + description = page.description, + imageUrl = page.thumbUrl(), + tag = null + ) + } ?: emptyList() + return content + } + + val readingHistory = historyEntryWithImageDao.getMostRecentEntriesWithImage(3) + if (readingHistory.size >= 3) { + val moreLikeSearchTerm = "morelike:${readingHistory.take(3).joinToString("|") { it.apiTitle }}" + val response = ServiceFactory.get(wikiSite).searchMoreLike(searchTerm = moreLikeSearchTerm, gsrLimit = 3, piLimit = 3) + val content = response.query?.pages?.map { page -> + FeedPreferenceContent( + title = page.title, + description = page.description, + imageUrl = page.thumbUrl(), + tag = null + ) + } ?: emptyList() + return content + } + + return listOf() + } + + fun saveFeedPreferenceSelection(preferenceType: FeedPreferenceType) { + Prefs.exploreFeedPreferenceSelection = preferenceType + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt new file mode 100644 index 00000000000..1e918cbe89f --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt @@ -0,0 +1,460 @@ +package org.wikipedia.feed.personalization.feedpreference + +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.painter.BrushPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.components.WikiCard +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.components.error.WikiErrorView +import org.wikipedia.compose.extensions.shimmerEffect +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun FeedPreferenceScreen( + modifier: Modifier = Modifier, + selectedType: FeedPreferenceType, + communityContentState: FeedContentState, + personalizedContentState: FeedContentState, + onTypeSelected: (FeedPreferenceType) -> Unit, + onRetryClick: (FeedPreferenceType) -> Unit +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.explore_feed_preference_selection_screen_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.primaryColor + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + FeedPreferenceSection( + state = communityContentState, + isSelected = selectedType == FeedPreferenceType.COMMUNITY, + feedPreferenceType = FeedPreferenceType.COMMUNITY, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + item { + FeedPreferenceSection( + state = personalizedContentState, + isSelected = selectedType == FeedPreferenceType.PERSONALIZED, + feedPreferenceType = FeedPreferenceType.PERSONALIZED, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + } + } +} + +@Composable +fun FeedPreferenceSection( + state: FeedContentState, + isSelected: Boolean, + feedPreferenceType: FeedPreferenceType, + onRetryClick: (FeedPreferenceType) -> Unit, + onSelected: (FeedPreferenceType) -> Unit +) { + val transition = rememberInfiniteTransition(label = "feedPreferenceShimmerTransition") + val isPersonalizedContentDisabled = state is FeedContentState.Empty + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clickable(onClick = { if (!isPersonalizedContentDisabled) onSelected(feedPreferenceType) }), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { onSelected(feedPreferenceType) }, + enabled = !isPersonalizedContentDisabled, + colors = RadioButtonDefaults.colors( + selectedColor = WikipediaTheme.colors.primaryColor, + unselectedColor = WikipediaTheme.colors.primaryColor, + disabledUnselectedColor = WikipediaTheme.colors.inactiveColor, + disabledSelectedColor = WikipediaTheme.colors.inactiveColor + ) + ) + Text( + text = stringResource(feedPreferenceType.titleRes), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (isPersonalizedContentDisabled) FontWeight.Normal else FontWeight.Medium + ), + color = if (isPersonalizedContentDisabled) WikipediaTheme.colors.inactiveColor else + WikipediaTheme.colors.primaryColor + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + when (state) { + is FeedContentState.Error -> { + item { + Box( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + caught = state.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = { onRetryClick(feedPreferenceType) } + ) + ) + } + } + } + + FeedContentState.Loading -> { + items(3) { + Box( + modifier = Modifier + .width(185.dp) + .height(230.dp) + .clip(RoundedCornerShape(size = 12.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + FeedContentState.Empty -> { + item { + Text( + modifier = Modifier.fillParentMaxWidth(), + text = stringResource(R.string.explore_feed_personalized_preference_empty_state_text), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + } + } + + is FeedContentState.Success -> { + items(state.content) { content -> + FeedPreferenceArticleCard( + content = content, + feedPreferenceType = feedPreferenceType + ) + } + } + } + } + } +} + +@Composable +fun FeedPreferenceArticleCard( + modifier: Modifier = Modifier, + feedPreferenceType: FeedPreferenceType, + content: FeedPreferenceContent +) { + WikiCard( + modifier = modifier + .width(185.dp) + .height(230.dp), + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor) + ) { + Column(modifier = Modifier.fillMaxHeight()) { + Box( + modifier = Modifier + .height(108.dp) + ) { + val request = ImageService.getRequest( + LocalContext.current, + url = content.imageUrl, + detectFace = true + ) + AsyncImage( + model = request, + placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + contentScale = ContentScale.Crop, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(108.dp) + ) + if (!content.tag.isNullOrEmpty()) { + ArticleCardTag( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background( + when (feedPreferenceType) { + FeedPreferenceType.COMMUNITY -> WikipediaTheme.colors.progressiveColor + FeedPreferenceType.PERSONALIZED -> WikipediaTheme.colors.successColor + }, shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + text = content.tag + ) + } + } + Column( + modifier = Modifier + .padding(16.dp) + .weight(1f) + ) { + if (!content.title.isNullOrEmpty()) { + HtmlText( + text = content.title, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ), + maxLines = 2, + color = WikipediaTheme.colors.primaryColor, + ) + } + + if (!content.description.isNullOrEmpty()) { + HtmlText( + text = content.description, + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor, + maxLines = if (!content.title.isNullOrEmpty()) 3 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +fun ArticleCardTag( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.backgroundColor + ) +} + +@Preview(showBackground = true) +@Composable +private fun FeedPreferenceScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = FeedPreferenceType.COMMUNITY, + communityContentState = FeedContentState.Success( + content = listOf( + FeedPreferenceContent( + title = "Winter Paralympics", + description = "2026 Winter Olympics Multi-sport event in Italy", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "In the news" + ), + FeedPreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ), + FeedPreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ) + ) + ), + personalizedContentState = FeedContentState.Success( + content = listOf( + FeedPreferenceContent( + title = "Personalized Content", + description = "See content that’s personalized for you based on your reading history and interests.", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Personalized" + ) + ) + ), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, fontScale = 1.5f, device = Devices.PIXEL_9) +@Composable +private fun FeedPreferenceScreenScaledTextPreview() { + BaseTheme( + currentTheme = Theme.DARK + ) { + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = FeedPreferenceType.COMMUNITY, + communityContentState = FeedContentState.Success( + content = listOf( + FeedPreferenceContent( + title = "Winter Paralympics", + description = "2026 Winter Olympics Multi-sport event in Italy", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "In the news" + ), + FeedPreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ), + FeedPreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ) + ) + ), + personalizedContentState = FeedContentState.Success( + content = listOf( + FeedPreferenceContent( + title = "Post's lattice", + description = "Lattice in universal algebra", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Logic" + ), + FeedPreferenceContent( + title = "Ranunculaceae", + description = "Family of eudicot flowering plants", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Nature" + ) + ) + ), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun FeedPreferenceScreenLoadingPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = FeedPreferenceType.COMMUNITY, + communityContentState = FeedContentState.Loading, + personalizedContentState = FeedContentState.Loading, + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun FeedPreferenceScreenErrorPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = FeedPreferenceType.COMMUNITY, + communityContentState = FeedContentState.Error(Throwable("Failed to load community content")), + personalizedContentState = FeedContentState.Error(Throwable("Failed to load personalized content")), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun FeedPreferenceScreenEmptyPersonalizedContentPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = FeedPreferenceType.COMMUNITY, + communityContentState = FeedContentState.Loading, + personalizedContentState = FeedContentState.Empty, + onTypeSelected = {}, + onRetryClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt new file mode 100644 index 00000000000..cd128ee075f --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt @@ -0,0 +1,28 @@ +package org.wikipedia.feed.personalization.feedpreference + +import org.wikipedia.R + +enum class FeedPreferenceType(val titleRes: Int) { + COMMUNITY(R.string.explore_feed_preference_community_content_title), + PERSONALIZED(R.string.explore_feed_preference_personalized_content_title) +} + +data class FeedPreferenceContent ( + val title: String?, + val description: String?, + val imageUrl: String?, + val tag: String? +) + +sealed interface FeedContentState { + data object Loading : FeedContentState + data object Empty : FeedContentState + data class Success(val content: List) : FeedContentState + data class Error(val message: Throwable) : FeedContentState +} + +data class FeedPreferenceUiState( + val selectedType: FeedPreferenceType = FeedPreferenceType.COMMUNITY, + val communityState: FeedContentState = FeedContentState.Loading, + val personalizedState: FeedContentState = FeedContentState.Loading +) diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt index 38e5a9ea290..854b17f45b7 100644 --- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt +++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt @@ -64,6 +64,10 @@ interface HistoryEntryWithImageDao { else SearchResults(entries.take(3).map { SearchResult(toHistoryEntry(it).title, SearchResult.SearchResultType.HISTORY) }.toMutableList()) } + suspend fun getMostRecentEntriesWithImage(limit: Int): List { + return getHistoryEntriesWithOffset(limit, 0).map { toHistoryEntry(it) } + } + suspend fun filterHistoryItemsWithoutTime(searchQuery: String = ""): List { return findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%").map { toHistoryEntry(it) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index bf426833bca..ff378168668 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -58,7 +58,7 @@ class InitialOnboardingActivity : BaseActivity() { currentNavigationBarColor = ResourceUtil.getThemedColor(this, R.attr.paper_color) }, onFinish = { - Prefs.isInitialOnboardingEnabled = false + // Prefs.isInitialOnboardingEnabled = false Prefs.isExploreFeedUpdatePromptShown = true startActivity(PersonalizationActivity.newIntent(this)) finish() diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index c9476e684e3..c42b5e626c0 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -14,6 +14,7 @@ import org.wikipedia.analytics.eventplatform.AppSessionEvent import org.wikipedia.dataclient.WikiSite import org.wikipedia.donate.DonationResult import org.wikipedia.donate.donationreminder.DonationReminderConfig +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceType import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageTitle @@ -884,4 +885,10 @@ object Prefs { var isExploreFeedUpdatePromptShown get() = PrefsIoUtil.getBoolean(R.string.preference_key_explore_feed_update_prompt_shown, false) set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_explore_feed_update_prompt_shown, value) + + var exploreFeedPreferenceSelection: FeedPreferenceType + get() = PrefsIoUtil.getString(R.string.preference_key_explore_feed_preference_selection, null)?.let { + FeedPreferenceType.valueOf(it) + } ?: FeedPreferenceType.COMMUNITY + set(value) = PrefsIoUtil.setString(R.string.preference_key_explore_feed_preference_selection, value.name) } diff --git a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt index 32c7e0421a7..4b36969e26c 100644 --- a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt @@ -18,6 +18,7 @@ import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite import org.wikipedia.donate.donationreminder.DonationReminderConfig +import org.wikipedia.feed.personalization.feedpreference.FeedPreferenceType import org.wikipedia.games.onthisday.OnThisDayGameNotificationManager import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.history.HistoryEntry @@ -268,6 +269,23 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom findPreference(R.string.preference_key_event_platform_intake_base_uri).summary = selectedState true } + (findPreference(R.string.preference_key_explore_feed_preference_selection) as ListPreference).apply { + value = Prefs.exploreFeedPreferenceSelection.name + val states = FeedPreferenceType.entries + val names = states.map { it.name }.toTypedArray() + entries = names + entryValues = names + setOnPreferenceChangeListener { _, newValue -> + val selectedState = newValue as String + val source = when (selectedState) { + "COMMUNITY" -> FeedPreferenceType.COMMUNITY + "PERSONALIZED" -> FeedPreferenceType.PERSONALIZED + else -> FeedPreferenceType.COMMUNITY + } + Prefs.exploreFeedPreferenceSelection = source + true + } + } } private fun setUpMediaWikiSettings() { diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index a1811df0000..2467a96a855 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2232,5 +2232,9 @@ Button text to deselect all selected items in the intereset selection screen. Description of the Picture of the Day card in the Home feed. Description of the Featured Article card in the Home feed. + Screen title prompting the user to choose which type of content should appear first in the explore feed. + Option label for selecting community-related content in the preference selection screen. + Option label for selecting personalized content based on user interests in preference selection screen. Description of the In the News card in the Home feed. + Message shown when no interests are selected, informing the user that they need to add interests to receive personalized content recommendations and guiding them to previous steps or Settings. diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index f6aba38d434..e77b9372016 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -209,4 +209,5 @@ hybridSearchEnabled gameStatsSnackbarShown exploreFeedUpdatePromptShown + exploreFeedPreferenceSelection diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 537d9c1f7af..800dd1a0847 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2485,5 +2485,9 @@ Deselect all Daily images on Wikimedia Commons, selected by volunteer contributors Featured articles are some of the best articles on Wikipedia and they are updated daily + What would you like to see first? + Community-related content + Personalized content Articles that have been substantially updated to reflect recent or current events of wide interest + You need to add interests to see personalized content recommendations. You can do this in the previous steps or later in Settings. \ No newline at end of file diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml index f5bc77486ab..4f164fd28fd 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -581,5 +581,8 @@ +