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 61a99e3bc3d..974891ebc81 100644 --- a/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt @@ -13,6 +13,7 @@ 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.homepreference.HomePreferenceType import org.wikipedia.feed.topread.TopRead import org.wikipedia.settings.Prefs import java.time.LocalDate @@ -57,7 +58,9 @@ class HomeViewModel : ViewModel() { private val _wikiSite = MutableStateFlow(WikiSite.forLanguageCode(Prefs.homeLanguageCode)) val wikiSite = _wikiSite.asStateFlow() - private val _selectedTab = MutableStateFlow(HomeTab.COMMUNITY) + private val _selectedTab = MutableStateFlow( + if (Prefs.homePreferenceSelection == HomePreferenceType.PERSONALIZED) HomeTab.FOR_YOU else HomeTab.COMMUNITY + ) val selectedTab = _selectedTab.asStateFlow() private val _communityState = MutableStateFlow(CommunityContentState()) @@ -89,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/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt index a26aa7d4b7b..130a19b8b9f 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt @@ -10,6 +10,7 @@ import org.wikipedia.Constants import org.wikipedia.activity.BaseActivity import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.feed.onboarding.ExploreFeedBuildingActivity import org.wikipedia.page.PageTitle import org.wikipedia.search.SearchActivity import org.wikipedia.util.FeedbackUtil @@ -42,6 +43,10 @@ class PersonalizationActivity : BaseActivity() { val intent = SearchActivity.newIntent(this, Constants.InvokeSource.FEED_INTEREST_SELECTION, null, returnLink = true) searchLauncher.launch(intent) }, + onCompleteOnboardingClick = { + startActivity(ExploreFeedBuildingActivity.newIntent(this)) + finish() + }, showError = { message -> FeedbackUtil.showError(this, message) } 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..f4d0c1553f0 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.homepreference.FeedPreferenceScreen import org.wikipedia.feed.personalization.interest.InterestOnboardingScreen // TODO: probably renaming the screen name @@ -40,12 +41,14 @@ fun PersonalizationScreen( modifier: Modifier = Modifier, screens: List, onSkipClick: () -> Unit, + onCompleteOnboardingClick: () -> Unit, onSearchClick: () -> Unit, showError: (Throwable) -> Unit, viewModel: PersonalizationViewModel ) { val coroutineScope = rememberCoroutineScope() val interestUiState = viewModel.interestUiState.collectAsState() + val feedPreferenceUiState = viewModel.feedPreferenceUiState.collectAsState() val pagerState = rememberPagerState(pageCount = { screens.size }) LaunchedEffect(pagerState.currentPage) { @@ -61,7 +64,7 @@ fun PersonalizationScreen( if (pagerState.currentPage < pagerState.pageCount - 1) { pagerState.animateScrollToPage(pagerState.currentPage + 1) } else { - onSkipClick() + onCompleteOnboardingClick() } } }, @@ -101,13 +104,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..0fb969ba05f 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.homepreference.HomeContentState +import org.wikipedia.feed.personalization.homepreference.HomePreferenceContent +import org.wikipedia.feed.personalization.homepreference.HomePreferenceRepository +import org.wikipedia.feed.personalization.homepreference.HomePreferenceType +import org.wikipedia.feed.personalization.homepreference.HomePreferenceUiState import org.wikipedia.feed.personalization.interest.ArticlesState import org.wikipedia.feed.personalization.interest.InterestSelectionRepository import org.wikipedia.feed.personalization.interest.InterestUiState @@ -36,8 +41,16 @@ private data class PersonalizedViewModelState( val articlesLoading: Boolean = false, val articlesError: Throwable? = null, val selectedArticles: Set = emptySet(), - val selectedTopics: List = emptyList() + val selectedTopics: List = emptyList(), + val topicPreviewContent: Map> = emptyMap(), // Feed preference screen properties + val homePreferenceType: HomePreferenceType = HomePreferenceType.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 +77,27 @@ private data class PersonalizedViewModelState( ) } - // Each screen in the personalization flow would have its own function - // fun toFeedPreferenceUiState(): FeedPreferenceUiState { ... } + fun toFeedPreferenceUiState(): HomePreferenceUiState { + return HomePreferenceUiState( + selectedType = homePreferenceType, + communityState = when { + communityLoading -> HomeContentState.Loading + communityError != null -> HomeContentState.Error(communityError) + else -> HomeContentState.Success(communityContent) + }, + personalizedState = when { + personalizedLoading -> HomeContentState.Loading + personalizedError != null -> HomeContentState.Error(personalizedError) + personalizedContent.isEmpty() -> HomeContentState.Empty + else -> HomeContentState.Success(personalizedContent) + } + ) + } } class PersonalizationViewModel( - private val interestSelectionRepository: InterestSelectionRepository + private val interestSelectionRepository: InterestSelectionRepository, + private val homePreferenceRepository: HomePreferenceRepository ) : 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 +113,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 +138,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 = homePreferenceRepository.getCommunityPreviewContent() + 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 = homePreferenceRepository.getPersonalizedPreviewContent( + selectedArticles = state.value.selectedArticles, + contentByTopic = state.value.topicPreviewContent + ) + state.update { it.copy(personalizedContent = personalizedContent, personalizedLoading = false) } + } + } + private suspend fun loadTopics() { if (state.value.topics.isNotEmpty()) return @@ -137,9 +206,9 @@ class PersonalizationViewModel( ) } - val lasTopic = persistedTopics.lastOrNull() - if (lasTopic != null) { - loadArticlesByTopic(topic = lasTopic) + val lastTopic = persistedTopics.lastOrNull() + if (lastTopic != null) { + loadArticlesByTopic(topic = lastTopic) } else { loadInitialArticles() } @@ -178,9 +247,10 @@ class PersonalizationViewModel( state.update { it.copy(articlesLoading = true, articlesError = null) } val articles = interestSelectionRepository.getArticlesByTopic(topic.queryTopicId) + val previewContent = HomePreferenceContent.fromPageTitles(pageTitles = articles, topic = topic) state.update { current -> val newArticles = (current.selectedArticles.toList() + articles).distinct() - current.copy(articles = newArticles, articlesLoading = false) + current.copy(articles = newArticles, topicPreviewContent = current.topicPreviewContent + (topic.topicId to previewContent), articlesLoading = false) } } } @@ -211,6 +281,11 @@ class PersonalizationViewModel( state.update { current -> current.copy( selectedTopics = selectedTopics, + topicPreviewContent = if (isSelected) { + current.topicPreviewContent - topic.topicId + } else { + current.topicPreviewContent + }, articles = emptyList(), articlesError = null ) @@ -274,6 +349,7 @@ class PersonalizationViewModel( it.copy( selectedArticles = emptySet(), selectedTopics = emptyList(), + topicPreviewContent = emptyMap(), articlesLoading = false, articlesError = null ) @@ -281,7 +357,7 @@ class PersonalizationViewModel( } } - fun retryLoading() { + fun retryInterestsLoading() { val last = state.value.selectedTopics.lastOrNull() if (last != null) { loadArticlesByTopic(topic = last) @@ -290,16 +366,35 @@ class PersonalizationViewModel( } } + fun onFeedPreferenceTypeSelected(type: HomePreferenceType) { + homePreferenceRepository.savePreference(type) + state.update { it.copy(homePreferenceType = type) } + } + + fun retryFeedPreferenceLoading(type: HomePreferenceType) { + when (type) { + HomePreferenceType.COMMUNITY -> loadCommunityPreviewContent() + HomePreferenceType.PERSONALIZED -> loadPersonalizedPreviewContent() + } + } + companion object { val Factory = viewModelFactory { initializer { + val appDatabase = AppDatabase.instance + val instance = WikipediaApp.instance PersonalizationViewModel( interestSelectionRepository = InterestSelectionRepository( - interestTopicDao = AppDatabase.instance.topicInterestDao(), - interestArticleDao = AppDatabase.instance.articleInterestDao(), + interestTopicDao = appDatabase.topicInterestDao(), + interestArticleDao = appDatabase.articleInterestDao(), + historyEntryWithImageDao = appDatabase.historyEntryWithImageDao(), + readingListPageDao = appDatabase.readingListPageDao(), + wikiSite = instance.wikiSite + ), + homePreferenceRepository = HomePreferenceRepository( + context = instance, historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), - readingListPageDao = AppDatabase.instance.readingListPageDao(), - wikiSite = WikipediaApp.instance.wikiSite + wikiSite = instance.wikiSite ) ) } diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt new file mode 100644 index 00000000000..b480b1019cc --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt @@ -0,0 +1,117 @@ +package org.wikipedia.feed.personalization.homepreference + +import android.content.Context +import org.wikipedia.R +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.getString +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 HomePreferenceRepository( + private val context: Context, + private val historyEntryWithImageDao: HistoryEntryWithImageDao, + private val wikiSite: WikiSite +) { + suspend fun getCommunityPreviewContent(): 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 -> + HomePreferenceContent( + title = article.displayTitle, + description = article.description, + imageUrl = article.thumbnailUrl, + tag = context.getString(wikiSite.languageCode, R.string.view_featured_article_card_title) + ) + } + + val pictureOfTheDay = response.potd?.let { potd -> + HomePreferenceContent( + title = null, + description = potd.description.text, + imageUrl = potd.thumbnailUrl, + tag = context.getString(wikiSite.languageCode, R.string.view_featured_image_card_title) + ) + } + + val topNewsItem = response.news?.firstOrNull()?.let { newsItem -> + HomePreferenceContent( + title = null, + description = StringUtil.removeHTMLTags(newsItem.story), + imageUrl = newsItem.thumbUrl(), + tag = context.getString(wikiSite.languageCode, R.string.view_card_news_title) + ) + } + + return listOfNotNull( + featuredArticle, + pictureOfTheDay, + topNewsItem + ) + } + + suspend fun getPersonalizedPreviewContent( + selectedArticles: Set, + contentByTopic: Map>, + ): List { + if (contentByTopic.isNotEmpty()) { + return sampleAcrossTopics(contentByTopic = contentByTopic) + } + + if (selectedArticles.isNotEmpty()) { + return fetchMoreLike(seeds = selectedArticles.map { it.prefixedText }) + } + + val recentHistoryEntries = historyEntryWithImageDao.getMostRecentEntriesWithImage(3) + if (recentHistoryEntries.size >= 3) { + return fetchMoreLike(seeds = recentHistoryEntries.map { it.apiTitle }) + } + + return listOf() + } + + // has count logic for cases where user has selected less than 3 topics + // as we need 3 articles to show in the preview, we need to distribute them across the selected topics + private fun sampleAcrossTopics( + contentByTopic: Map>, + totalCount: Int = 3, + ): List { + val topicIds = contentByTopic.keys.toList().reversed() + + val baseLimit = totalCount / topicIds.size + val remainder = totalCount % topicIds.size + + return topicIds.flatMapIndexed { index, topic -> + val count = baseLimit + if (index < remainder) 1 else 0 + contentByTopic[topic].orEmpty().take(count) + } + } + + private suspend fun fetchMoreLike(seeds: List): List { + if (seeds.isEmpty()) return emptyList() + val moreLikeSearchTerm = "morelike:${seeds.take(3).joinToString("|")}" + val response = ServiceFactory.get(wikiSite).searchMoreLike(searchTerm = moreLikeSearchTerm, gsrLimit = 3, piLimit = 3) + return response.query?.pages?.map { page -> + HomePreferenceContent( + title = page.displayTitle(wikiSite.languageCode), + description = page.description, + imageUrl = page.thumbUrl(), + tag = null + ) + } ?: emptyList() + } + + fun savePreference(preferenceType: HomePreferenceType) { + Prefs.homePreferenceSelection = preferenceType + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt new file mode 100644 index 00000000000..0038f6b5130 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt @@ -0,0 +1,460 @@ +package org.wikipedia.feed.personalization.homepreference + +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: HomePreferenceType, + communityContentState: HomeContentState, + personalizedContentState: HomeContentState, + onTypeSelected: (HomePreferenceType) -> Unit, + onRetryClick: (HomePreferenceType) -> 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 == HomePreferenceType.COMMUNITY, + homePreferenceType = HomePreferenceType.COMMUNITY, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + item { + FeedPreferenceSection( + state = personalizedContentState, + isSelected = selectedType == HomePreferenceType.PERSONALIZED, + homePreferenceType = HomePreferenceType.PERSONALIZED, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + } + } +} + +@Composable +fun FeedPreferenceSection( + state: HomeContentState, + isSelected: Boolean, + homePreferenceType: HomePreferenceType, + onRetryClick: (HomePreferenceType) -> Unit, + onSelected: (HomePreferenceType) -> Unit +) { + val transition = rememberInfiniteTransition(label = "feedPreferenceShimmerTransition") + val isPersonalizedContentDisabled = state is HomeContentState.Empty + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clickable(onClick = { if (!isPersonalizedContentDisabled) onSelected(homePreferenceType) }), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { onSelected(homePreferenceType) }, + enabled = !isPersonalizedContentDisabled, + colors = RadioButtonDefaults.colors( + selectedColor = WikipediaTheme.colors.primaryColor, + unselectedColor = WikipediaTheme.colors.primaryColor, + disabledUnselectedColor = WikipediaTheme.colors.inactiveColor, + disabledSelectedColor = WikipediaTheme.colors.inactiveColor + ) + ) + Text( + text = stringResource(homePreferenceType.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 HomeContentState.Error -> { + item { + Box( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + caught = state.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = { onRetryClick(homePreferenceType) } + ) + ) + } + } + } + + HomeContentState.Loading -> { + items(3) { + Box( + modifier = Modifier + .width(185.dp) + .height(230.dp) + .clip(RoundedCornerShape(size = 12.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + HomeContentState.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 HomeContentState.Success -> { + items(state.content) { content -> + FeedPreferenceArticleCard( + content = content, + homePreferenceType = homePreferenceType + ) + } + } + } + } + } +} + +@Composable +fun FeedPreferenceArticleCard( + modifier: Modifier = Modifier, + homePreferenceType: HomePreferenceType, + content: HomePreferenceContent +) { + 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 (homePreferenceType) { + HomePreferenceType.COMMUNITY -> WikipediaTheme.colors.progressiveColor + HomePreferenceType.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 = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + 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" + ), + HomePreferenceContent( + 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" + ), + HomePreferenceContent( + 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 = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + 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 = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + 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" + ), + HomePreferenceContent( + 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" + ), + HomePreferenceContent( + 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 = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + 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" + ), + HomePreferenceContent( + 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 = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Loading, + personalizedContentState = HomeContentState.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 = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Error(Throwable("Failed to load community content")), + personalizedContentState = HomeContentState.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 = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Loading, + personalizedContentState = HomeContentState.Empty, + onTypeSelected = {}, + onRetryClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt new file mode 100644 index 00000000000..48f78aef93e --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt @@ -0,0 +1,43 @@ +package org.wikipedia.feed.personalization.homepreference + +import org.wikipedia.R +import org.wikipedia.feed.personalization.interest.OnboardingTopic +import org.wikipedia.page.PageTitle + +enum class HomePreferenceType(val titleRes: Int) { + COMMUNITY(R.string.explore_feed_preference_community_content_title), + PERSONALIZED(R.string.explore_feed_preference_personalized_content_title) +} + +data class HomePreferenceContent ( + val title: String?, + val description: String?, + val imageUrl: String?, + val tag: String? +) { + companion object { + fun fromPageTitles(pageTitles: List, topic: OnboardingTopic): List { + return pageTitles.map { page -> + HomePreferenceContent( + title = page.displayText, + description = page.description, + imageUrl = page.thumbUrl, + tag = topic.displayTitle + ) + } + } + } +} + +sealed interface HomeContentState { + data object Loading : HomeContentState + data object Empty : HomeContentState + data class Success(val content: List) : HomeContentState + data class Error(val message: Throwable) : HomeContentState +} + +data class HomePreferenceUiState( + val selectedType: HomePreferenceType = HomePreferenceType.COMMUNITY, + val communityState: HomeContentState = HomeContentState.Loading, + val personalizedState: HomeContentState = HomeContentState.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/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index c84f314df75..58f763bcd22 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.homepreference.HomePreferenceType import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageTitle @@ -888,4 +889,10 @@ object Prefs { var homeLanguageCode get() = PrefsIoUtil.getString(R.string.preference_key_home_language_code, WikipediaApp.instance.appOrSystemLanguageCode)!! set(value) = PrefsIoUtil.setString(R.string.preference_key_home_language_code, value) + + var homePreferenceSelection: HomePreferenceType + get() = PrefsIoUtil.getString(R.string.preference_key_home_preference_selection, null)?.let { + HomePreferenceType.valueOf(it) + } ?: HomePreferenceType.COMMUNITY + set(value) = PrefsIoUtil.setString(R.string.preference_key_home_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..5a1d65207f3 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.homepreference.HomePreferenceType 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_home_preference_selection) as ListPreference).apply { + value = Prefs.homePreferenceSelection.name + val states = HomePreferenceType.entries + val names = states.map { it.name }.toTypedArray() + entries = names + entryValues = names + setOnPreferenceChangeListener { _, newValue -> + val selectedState = newValue as String + val source = when (selectedState) { + "COMMUNITY" -> HomePreferenceType.COMMUNITY + "PERSONALIZED" -> HomePreferenceType.PERSONALIZED + else -> HomePreferenceType.COMMUNITY + } + Prefs.homePreferenceSelection = 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 4bd0313c831..b3260ca58b1 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2233,9 +2233,13 @@ 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. Description of the On This Day card in the Home feed. Text for the disclaimer at the top of the community tab in the Home feed. Label text for the community tab in Home feed to load more content. Label text in the languages pop-up menu in the Home feed to manauge the app languages. + 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 69611d4d3de..a67f8195409 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -210,4 +210,5 @@ gameStatsSnackbarShown exploreFeedUpdatePromptShown homeLanguageCode + exploreFeedPreferenceSelection diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0b97f32738..4ca36c7062b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2485,10 +2485,14 @@ Select topics that interest you, and we will personalize your feed.\n\nWe collect minimal data that is anonymized. Deselect all Daily images on Wikimedia Commons, selected by volunteer contributors - Featured articles are some of the best articles on Wikipedia, selected daily by editors + 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 Discover historical events from this day Content and resources selected by and about the Wikimedia community See past community content Manage languages + 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..d6cca0f2dac 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -581,5 +581,8 @@ +