From 08fa4d4dc447edf3540422af07fdd34a0d31a023 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 27 Mar 2026 10:01:48 -0400 Subject: [PATCH 01/39] - adds PersonalizationScreen.kt - adds interest onboarding related screens - adds string resources --- .../onboarding/InitialOnboardingActivity.kt | 19 ++- .../personalization/InterestOnboarding.kt | 93 +++++++++++++ .../personalization/PersonalizationScreen.kt | 124 ++++++++++++++++++ app/src/main/res/values-qq/strings.xml | 2 + app/src/main/res/values/strings.xml | 4 + 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index 76415965ad0..b82286d68ce 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -20,6 +20,7 @@ import org.wikipedia.activity.BaseActivity import org.wikipedia.compose.components.AppTextButton import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.onboarding.personalization.PersonalizationScreen import org.wikipedia.settings.Prefs class InitialOnboardingActivity : BaseActivity() { @@ -32,7 +33,7 @@ class InitialOnboardingActivity : BaseActivity() { InitialOnboardingScreen( isNewUser = Prefs.isInitialOnboardingEnabled, onFinish = { - Prefs.isInitialOnboardingEnabled = false + // Prefs.isInitialOnboardingEnabled = false setResult(RESULT_OK) finish() } @@ -55,11 +56,17 @@ fun InitialOnboardingScreen( isNewUser: Boolean, onFinish: () -> Unit ) { - var showPersonalization by remember { mutableStateOf(isNewUser) } - if (showPersonalization) { - IntroScreen(onClick = onFinish ) - } else { + var showInterestOnboarding by remember { mutableStateOf(false) } + var showIntroScreen by remember { mutableStateOf(isNewUser) } + if (showIntroScreen) { + IntroScreen(onClick = { + showInterestOnboarding = true + }) + } + + if (showInterestOnboarding) { // Personalization Screen (interest selection + content preference + language) + PersonalizationScreen() } } @@ -79,7 +86,7 @@ fun IntroScreen( AppTextButton ( onClick = onClick ) { - Text("Skip", color = WikipediaTheme.colors.progressiveColor) + Text("Next", color = WikipediaTheme.colors.progressiveColor) } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt new file mode 100644 index 00000000000..0db5b7b5cdb --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt @@ -0,0 +1,93 @@ +package org.wikipedia.onboarding.personalization + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.request.allowHardware +import org.wikipedia.R +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme +import org.wikipedia.yearinreview.LoadingIndicator + +@Composable +fun OnboardingCuriosityScreen( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Spacer(modifier = Modifier.weight(1f)) + + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(R.drawable.yir_puzzle_browser) + .allowHardware(false) + .build(), + loading = { LoadingIndicator() }, + success = { + SubcomposeAsyncImageContent() + }, + contentDescription = stringResource(R.string.explore_feed_onboarding_curiosity_title), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + text = stringResource(R.string.explore_feed_onboarding_curiosity_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = stringResource(R.string.explore_feed_onboarding_curiosity_description), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +data class InterestOnboardingUiState( + val isLoading: Boolean = true, + val items: List = emptyList(), + val selectedItems: Set = emptySet() +) + +@Preview +@Composable +private fun OnboardingCuriosityScreenPreview() { + BaseTheme ( + currentTheme = Theme.LIGHT + ) { + OnboardingCuriosityScreen() + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt new file mode 100644 index 00000000000..0acb7e16c96 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -0,0 +1,124 @@ +package org.wikipedia.onboarding.personalization + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.compose.components.PageIndicator +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme + +// TODO: probably renaming the screen name +@Composable +fun PersonalizationScreen( + modifier: Modifier = Modifier +) { + val pagerState = rememberPagerState(pageCount = { 3 }) + Scaffold( + bottomBar = { + OnboardingBottomBar( + pagerState = pagerState, + onNavigationRightClick = { /*TODO*/ }, + onSkipClick = { /*TODO*/ } + ) + }, + containerColor = WikipediaTheme.colors.paperColor + ) { paddingValues -> + Column( + modifier = modifier.padding(paddingValues) + ) { + HorizontalPager( + state = pagerState + ) { page -> + when (page) { + 0 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) + 1 -> {} + 2 -> {} + } + } + } + } +} + +@Composable +fun OnboardingBottomBar( + pagerState: PagerState, + onNavigationRightClick: () -> Unit, + onSkipClick: () -> Unit, +) { + Column { + HorizontalDivider( + modifier = Modifier + .height(1.dp) + .fillMaxWidth(), + color = WikipediaTheme.colors.borderColor + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { onSkipClick() }, + modifier = Modifier + .wrapContentWidth(Alignment.Start) + .wrapContentHeight(Alignment.CenterVertically) + ) { + Text( + text = stringResource(id = R.string.onboarding_skip), + color = WikipediaTheme.colors.placeholderColor + ) + } + + PageIndicator( + modifier = Modifier + .weight(1f) + .wrapContentHeight(Alignment.CenterVertically), + pagerState = pagerState + ) + + IconButton( + onClick = { onNavigationRightClick() }, + modifier = Modifier + .wrapContentWidth(Alignment.End) + .wrapContentHeight(Alignment.CenterVertically) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_forward_black_24dp), + contentDescription = stringResource(id = R.string.onboarding_next), + tint = WikipediaTheme.colors.progressiveColor + ) + } + } + } +} + +@Preview +@Composable +private fun PersonalizationScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + PersonalizationScreen() + } +} diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 414116a5c9f..c23ed063e2a 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2201,4 +2201,6 @@ Title of a dialog box rewarding the user for completing the reading challenge. Body text of a dialog box rewarding the user for completing the reading challenge. Button label to navigate to the Wikipedia store with a discount, as a reward for completing the reading challenge. + Display name for the title of the Explore feed onboarding screen prompting users to follow topics. + Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bea7658e52..0cd4b0e675c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2452,4 +2452,8 @@ Curiosity looks good on you! 🛍️ Celebrate completing the challenge with 15% off at the Wikipedia Store. Get 15% off at the store + + + Follow your curiosity + Select topics that interest you and we will personalize your feed.\n\nWe collect minimal data that is anonymized. \ No newline at end of file From 327446e9bd2b25306631c3db0260cda9d8763b84 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 27 Mar 2026 16:39:16 -0400 Subject: [PATCH 02/39] - adds raw state for PersonalizationScreen to derive interest ui states - adds state models - adds viewModel and interest placeholder screen --- .../onboarding/InitialOnboardingActivity.kt | 7 +- .../InterestOnboardingScreen.kt | 52 ++++++++ ...arding.kt => OnboardingCuriosityScreen.kt} | 3 + .../personalization/PersonalizationScreen.kt | 33 ++++- .../personalization/PersonalizationState.kt | 29 +++++ .../PersonalizationViewModel.kt | 121 ++++++++++++++++++ 6 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt rename app/src/main/java/org/wikipedia/onboarding/personalization/{InterestOnboarding.kt => OnboardingCuriosityScreen.kt} (96%) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index b82286d68ce..1281906db2e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -22,12 +22,13 @@ import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.onboarding.personalization.PersonalizationScreen import org.wikipedia.settings.Prefs +import org.wikipedia.util.DeviceUtil class InitialOnboardingActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + DeviceUtil.setEdgeToEdge(this) setContent { BaseTheme { InitialOnboardingScreen( @@ -66,7 +67,9 @@ fun InitialOnboardingScreen( if (showInterestOnboarding) { // Personalization Screen (interest selection + content preference + language) - PersonalizationScreen() + PersonalizationScreen( + onSkipClick = onFinish + ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt new file mode 100644 index 00000000000..7ff5bfdab9b --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -0,0 +1,52 @@ +package org.wikipedia.onboarding.personalization + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.wikipedia.compose.theme.WikipediaTheme + +@Composable +fun InterestOnboardingScreen( + modifier: Modifier = Modifier, + categoriesState: CategoriesState, + articlesState: ArticlesState +) { + Scaffold( + containerColor = WikipediaTheme.colors.paperColor + ) { paddingValues -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (categoriesState) { + is CategoriesState.Error -> {} + CategoriesState.Loading -> {} + is CategoriesState.Success -> { + categoriesState.categories.forEach { + Text(text = it.title) + } + } + } + + when (articlesState) { + is ArticlesState.Error -> {} + ArticlesState.Loading -> {} + is ArticlesState.Success -> { + articlesState.articles.forEach { + Text(text = it.displayText) + } + } + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt similarity index 96% rename from app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt rename to app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt index 0db5b7b5cdb..1fcca5133f3 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboarding.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt @@ -7,6 +7,7 @@ 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.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,6 +42,8 @@ fun OnboardingCuriosityScreen( Spacer(modifier = Modifier.weight(1f)) SubcomposeAsyncImage( + modifier = Modifier + .size(125.dp), model = ImageRequest.Builder(LocalContext.current) .data(R.drawable.yir_puzzle_browser) .allowHardware(false) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 0acb7e16c96..4538b40b8b5 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth @@ -17,12 +18,15 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import org.wikipedia.R import org.wikipedia.compose.components.PageIndicator import org.wikipedia.compose.theme.BaseTheme @@ -32,28 +36,43 @@ import org.wikipedia.theme.Theme // TODO: probably renaming the screen name @Composable fun PersonalizationScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onSkipClick: () -> Unit, + viewModel: PersonalizationViewModel = viewModel() ) { + val uiState = viewModel.interestUiState.collectAsState() val pagerState = rememberPagerState(pageCount = { 3 }) + + LaunchedEffect(pagerState.currentPage) { + viewModel.onPageChanged(pagerState.currentPage) + } + Scaffold( bottomBar = { OnboardingBottomBar( pagerState = pagerState, onNavigationRightClick = { /*TODO*/ }, - onSkipClick = { /*TODO*/ } + onSkipClick = onSkipClick ) }, containerColor = WikipediaTheme.colors.paperColor ) { paddingValues -> Column( - modifier = modifier.padding(paddingValues) + modifier = modifier + .padding(paddingValues) ) { HorizontalPager( state = pagerState ) { page -> when (page) { 0 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) - 1 -> {} + 1 -> { + InterestOnboardingScreen( + categoriesState = uiState.value.categoriesState, + articlesState = uiState.value.articlesState, + modifier = Modifier + ) + } 2 -> {} } } @@ -76,6 +95,8 @@ fun OnboardingBottomBar( ) Row( + modifier = Modifier + .navigationBarsPadding(), verticalAlignment = Alignment.CenterVertically, ) { TextButton( @@ -119,6 +140,8 @@ private fun PersonalizationScreenPreview() { BaseTheme( currentTheme = Theme.LIGHT ) { - PersonalizationScreen() + PersonalizationScreen( + onSkipClick = {} + ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt new file mode 100644 index 00000000000..bb8e75086e2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -0,0 +1,29 @@ +package org.wikipedia.onboarding.personalization + +import org.wikipedia.page.PageTitle + +// TODO: update the states below as needed as we build out the screen +data class OnboardingCategory( + val id: String, + val title: String +) + +data class InterestUiState( + val categoriesState: CategoriesState = CategoriesState.Loading, + val articlesState: ArticlesState = ArticlesState.Loading, + val selectedCategory: String? = null, + val selectedArticles: Set = emptySet(), + val selectionCount: Int = 0, +) + +sealed interface CategoriesState { + data object Loading : CategoriesState + data class Success(val categories: List) : CategoriesState + data class Error(val message: String) : CategoriesState +} + +sealed interface ArticlesState { + data object Loading : ArticlesState + data class Success(val articles: List) : ArticlesState + data class Error(val message: String) : ArticlesState +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt new file mode 100644 index 00000000000..0c9c313047e --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -0,0 +1,121 @@ +package org.wikipedia.onboarding.personalization + +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.page.PageTitle + +private data class PersonalizedViewModelState( + // Interest screen + val categories: List = emptyList(), + val categoriesLoading: Boolean = false, + val categoriesError: Throwable? = null, + val selectedCategoryId: String? = null, + val articles: List = emptyList(), + val articlesLoading: Boolean = false, + val articlesError: Throwable? = null, + val selectedArticleIds: Set = emptySet(), + val searchQuery: String = "", + // Navigation + val currentPage: Int = 0 +) { + fun toInterestUiState(): InterestUiState { + return InterestUiState( + categoriesState = when { + categoriesLoading -> CategoriesState.Loading + categoriesError != null -> CategoriesState.Error( + categoriesError.message ?: "Unknown error" + ) + + else -> CategoriesState.Success(categories) + }, + articlesState = when { + articlesLoading -> ArticlesState.Loading + articlesError != null -> ArticlesState.Error( + articlesError.message ?: "Unknown error" + ) + + else -> ArticlesState.Success(articles) + }, + selectedCategory = selectedCategoryId, + selectedArticles = selectedArticleIds, + selectionCount = selectedArticleIds.size + ) + } + + // Note: the feed preference ui state can be defined as similar to the interest ui state with required properties + // and a mapping function to convert the overall view model state to the feed preference ui state + + // similarly, we can define ui states for the other screens in the personalization flow and mapping functions as needed + // instead of creating individual states or combining them +} + +class PersonalizationViewModel : ViewModel() { + private val state = MutableStateFlow(PersonalizedViewModelState()) + + val interestUiState = state + .map { it.toInterestUiState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = state.value.toInterestUiState() + ) + + fun onPageChanged(page: Int) { + when (page) { + 1 -> { + loadCategories() + loadArticlesForCategory("") + } + } + } + + fun loadCategories() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(categoriesLoading = false, categoriesError = throwable) } + }) { + val current = state.value + if (current.categories.isNotEmpty()) return@launch + + println("orange loading categories...") + delay(5000) // simulate network delay + val categories = listOf( + OnboardingCategory("1", "Science"), + OnboardingCategory("2", "History"), + OnboardingCategory("3", "Art") + ) + state.update { it.copy(categories = categories, categoriesLoading = false) } + } + } + + fun loadArticlesForCategory(categoryId: String) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } + }) { + val current = state.value + if (current.articles.isNotEmpty()) return@launch + println("orange loading articles for category $categoryId...") + delay(5000) // simulate network delay + val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") + val titles = listOf( + PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), + PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), + PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), + PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), + PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), + PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), + PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), + ) + state.update { it.copy(articles = titles, articlesLoading = false) } + } + } +} From 4d3ceba477085944d74f927dd341fbee495b4a8d Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 27 Mar 2026 17:09:08 -0400 Subject: [PATCH 03/39] - adds click event on category and adds comments --- .../InterestOnboardingScreen.kt | 36 +++++++++++++--- .../personalization/PersonalizationScreen.kt | 5 ++- .../PersonalizationViewModel.kt | 43 +++++++++++++++---- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 7ff5bfdab9b..c7cb5f2c357 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -4,18 +4,24 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import org.wikipedia.compose.theme.WikipediaTheme +// TODO: add actual UI @Composable fun InterestOnboardingScreen( modifier: Modifier = Modifier, categoriesState: CategoriesState, - articlesState: ArticlesState + articlesState: ArticlesState, + onCategorySelected: (OnboardingCategory) -> Unit ) { Scaffold( containerColor = WikipediaTheme.colors.paperColor @@ -30,20 +36,36 @@ fun InterestOnboardingScreen( ) { when (categoriesState) { is CategoriesState.Error -> {} - CategoriesState.Loading -> {} + CategoriesState.Loading -> { + Text("Loading categories...") + } is CategoriesState.Success -> { - categoriesState.categories.forEach { - Text(text = it.title) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(categoriesState.categories) { + Button( + onClick = { onCategorySelected(it) } + ) { + Text(text = it.title) + } + } } } } when (articlesState) { is ArticlesState.Error -> {} - ArticlesState.Loading -> {} + ArticlesState.Loading -> { + Text("Loading articles...") + } is ArticlesState.Success -> { - articlesState.articles.forEach { - Text(text = it.displayText) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + articlesState.articles.forEach { + Text(text = it.displayText) + } } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 4538b40b8b5..6c6ecf82c49 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -70,7 +70,10 @@ fun PersonalizationScreen( InterestOnboardingScreen( categoriesState = uiState.value.categoriesState, articlesState = uiState.value.articlesState, - modifier = Modifier + modifier = Modifier, + onCategorySelected = { + viewModel.onCategorySelected(it) + } ) } 2 -> {} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 0c9c313047e..3be4006fb07 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -14,6 +14,12 @@ import kotlinx.coroutines.launch import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.PageTitle +// TODO: remove comments once reviewed +// this is a is a raw, flat, internal representation of ALL state +// needed across the personalization flow (interest, feed preference, language screens) +// this enables SINGLE SOURCE OF TRUTH — one place to update, no risk of states going out of sync +// DERIVED UI STATES — each screen gets its own UI state derived from a function like toInterestUIState() +// instead of maintaining separate StateFlows per screen or one giant combined UI state private data class PersonalizedViewModelState( // Interest screen val categories: List = emptyList(), @@ -25,8 +31,8 @@ private data class PersonalizedViewModelState( val articlesError: Throwable? = null, val selectedArticleIds: Set = emptySet(), val searchQuery: String = "", - // Navigation - val currentPage: Int = 0 + // Feed preference screen properties + // Language screen properties ) { fun toInterestUiState(): InterestUiState { return InterestUiState( @@ -52,16 +58,17 @@ private data class PersonalizedViewModelState( ) } - // Note: the feed preference ui state can be defined as similar to the interest ui state with required properties - // and a mapping function to convert the overall view model state to the feed preference ui state - - // similarly, we can define ui states for the other screens in the personalization flow and mapping functions as needed - // instead of creating individual states or combining them + // Each screen in the personalization flow would have its own function + // fun toFeedPreferenceUiState(): FeedPreferenceUiState { ... } + // fun toLanguageUiState(): LanguageUiState { ... } } class PersonalizationViewModel : 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()) + // Each screen observes only its own derived UI state + // runs automatically when any part of the raw state changes val interestUiState = state .map { it.toInterestUiState() } .stateIn( @@ -79,10 +86,12 @@ class PersonalizationViewModel : ViewModel() { } } - fun loadCategories() { + // TODO: add actual api call + private fun loadCategories() { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(categoriesLoading = false, categoriesError = throwable) } }) { + state.update { it.copy(categoriesLoading = true) } val current = state.value if (current.categories.isNotEmpty()) return@launch @@ -97,10 +106,12 @@ class PersonalizationViewModel : ViewModel() { } } - fun loadArticlesForCategory(categoryId: String) { + // TODO: add actual api call + private fun loadArticlesForCategory(categoryId: String) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { + state.update { it.copy(articlesLoading = true) } val current = state.value if (current.articles.isNotEmpty()) return@launch println("orange loading articles for category $categoryId...") @@ -118,4 +129,18 @@ class PersonalizationViewModel : ViewModel() { state.update { it.copy(articles = titles, articlesLoading = false) } } } + + // as we have a single state it becomes easier to update and control the state + fun onCategorySelected(category: OnboardingCategory) { + // When a category is selected, we want to reset the articles state and load articles for the selected category + state.update { + it.copy( + selectedCategoryId = category.id, + articles = emptyList(), + articlesLoading = true, + articlesError = null + ) + } + loadArticlesForCategory(category.id) + } } From cc4df5ad5cb47c01c5ac29b94ceb5c35fed31293 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 27 Mar 2026 17:35:08 -0400 Subject: [PATCH 04/39] - adds personalization screen as activity - code fixes --- app/src/main/AndroidManifest.xml | 2 + .../java/org/wikipedia/feed/FeedFragment.kt | 2 +- .../onboarding/InitialOnboardingActivity.kt | 37 ++++--------------- .../PersonalizationActivity.kt | 27 ++++++++++++++ 4 files changed, 37 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e27c45fb4e2..1914ac0beb3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -416,6 +416,8 @@ + + Unit, - onFinish: () -> Unit -) { - var showIntroScreen by remember { mutableStateOf(isNewUser) } - if (showIntroScreen) { - InitialOnboardingScreen( - modifier = modifier, - onNextClick = { - onUpdateTheme() - }, - onFinishClick = { - showIntroScreen = false - } - ) - } else { - // TODO: interest selection - onFinish() - } -} - @Composable fun InitialOnboardingScreen( modifier: Modifier = Modifier, diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt new file mode 100644 index 00000000000..001d09ad044 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -0,0 +1,27 @@ +package org.wikipedia.onboarding.personalization + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import org.wikipedia.activity.BaseActivity +import org.wikipedia.compose.theme.BaseTheme + +class PersonalizationActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + BaseTheme { + PersonalizationScreen( + onSkipClick = { /*TODO*/ } + ) + } + } + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, PersonalizationActivity::class.java) + } + } +} From 2f8cd85bf76b63b3500f844b1468c182605c3c74 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 27 Mar 2026 17:44:17 -0400 Subject: [PATCH 05/39] - adds navigation --- .../personalization/PersonalizationActivity.kt | 3 ++- .../personalization/PersonalizationScreen.kt | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index 001d09ad044..abb0d937197 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -10,10 +10,11 @@ import org.wikipedia.compose.theme.BaseTheme class PersonalizationActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { BaseTheme { PersonalizationScreen( - onSkipClick = { /*TODO*/ } + onSkipClick = { finish() } ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 6c6ecf82c49..4402d1ded59 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -27,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.compose.components.PageIndicator import org.wikipedia.compose.theme.BaseTheme @@ -40,6 +42,7 @@ fun PersonalizationScreen( onSkipClick: () -> Unit, viewModel: PersonalizationViewModel = viewModel() ) { + val coroutineScope = rememberCoroutineScope() val uiState = viewModel.interestUiState.collectAsState() val pagerState = rememberPagerState(pageCount = { 3 }) @@ -51,7 +54,16 @@ fun PersonalizationScreen( bottomBar = { OnboardingBottomBar( pagerState = pagerState, - onNavigationRightClick = { /*TODO*/ }, + onNavigationRightClick = { + coroutineScope.launch { + if (pagerState.currentPage < pagerState.pageCount - 1) { + viewModel.onPageChanged(pagerState.currentPage + 1) + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } else { + onSkipClick() + } + } + }, onSkipClick = onSkipClick ) }, @@ -76,7 +88,7 @@ fun PersonalizationScreen( } ) } - 2 -> {} + 2 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) } } } From a26073a9fed0dfd77614064cd4fd131aee99f10e Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 30 Mar 2026 16:57:51 -0400 Subject: [PATCH 06/39] - adds more UI for interest selection and business logic - code fixes --- app/src/main/java/org/wikipedia/Constants.kt | 3 +- .../onboarding/InitialOnboardingActivity.kt | 2 +- .../InterestOnboardingScreen.kt | 159 +++++++++++++----- .../PersonalizationActivity.kt | 23 ++- .../PersonalizationRepository.kt | 98 +++++++++++ .../personalization/PersonalizationScreen.kt | 36 ++-- .../personalization/PersonalizationState.kt | 22 ++- .../PersonalizationViewModel.kt | 147 +++++++++------- 8 files changed, 352 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt diff --git a/app/src/main/java/org/wikipedia/Constants.kt b/app/src/main/java/org/wikipedia/Constants.kt index 11a4fef34c2..652ff1a4086 100644 --- a/app/src/main/java/org/wikipedia/Constants.kt +++ b/app/src/main/java/org/wikipedia/Constants.kt @@ -112,7 +112,8 @@ object Constants { SUGGESTED_EDITS_RECENT_EDITS("suggestedEditsRecentEdits"), ON_THIS_DAY_GAME_ACTIVITY("onThisDayGame"), ACTIVITY_TAB("activityTab"), - GAMES_HUB("gamesHub") + GAMES_HUB("gamesHub"), + INTEREST_SELECTION("interestSelection"), } enum class ImageEditType(name: String) { diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index 9e41d5b7ea1..f62127ce31d 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -82,7 +82,7 @@ class InitialOnboardingActivity : BaseActivity() { currentNavigationBarColor = ResourceUtil.getThemedColor(this, R.attr.paper_color) }, onFinishClick = { - Prefs.isInitialOnboardingEnabled = false + // Prefs.isInitialOnboardingEnabled = false Prefs.isExploreFeedUpdatePromptShown = true startActivity(PersonalizationActivity.newIntent(this)) finish() diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index c7cb5f2c357..aee3d494bcd 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -1,73 +1,146 @@ package org.wikipedia.onboarding.personalization +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer 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.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.Scaffold +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.wikipedia.R import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.page.PageTitle +import org.wikipedia.readinglist.recommended.ReadingListInterestCard +import org.wikipedia.readinglist.recommended.ReadingListInterestSearchCard // TODO: add actual UI @Composable fun InterestOnboardingScreen( modifier: Modifier = Modifier, - categoriesState: CategoriesState, + topicsState: TopicsState, articlesState: ArticlesState, - onCategorySelected: (OnboardingCategory) -> Unit + onCategorySelected: (OnboardingTopic) -> Unit, + onItemClick: (PageTitle) -> Unit = {}, + onSearchClick: () -> Unit ) { - Scaffold( - containerColor = WikipediaTheme.colors.paperColor - ) { paddingValues -> + val listState = rememberLazyStaggeredGridState() + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(id = R.string.recommended_reading_list_interest_pick_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Medium + ) + ) - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (categoriesState) { - is CategoriesState.Error -> {} - CategoriesState.Loading -> { - Text("Loading categories...") - } - is CategoriesState.Success -> { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(categoriesState.categories) { - Button( - onClick = { onCategorySelected(it) } - ) { - Text(text = it.title) - } - } + ReadingListInterestSearchCard( + onSearchClick = onSearchClick + ) + + when (topicsState) { + is TopicsState.Error -> {} + TopicsState.Loading -> { + Text("Loading categories...") + } + is TopicsState.Success -> { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(topicsState.topics) { item -> + FilterChip( + modifier = Modifier.fillMaxWidth(), + label = { Text(item.title) }, + selected = item.isSelected, + onClick = { onCategorySelected(item) }, + leadingIcon = { + AnimatedContent( + targetState = item.isSelected + ) { isSelected -> + Icon( + modifier = Modifier + .size(16.dp), + painter = if (isSelected) { + R.drawable.ic_check_black_24dp + } else { + R.drawable.ic_add_gray_white_24dp + }.let { painterResource(id = it) }, + contentDescription = null + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = WikipediaTheme.colors.backgroundColor, + labelColor = WikipediaTheme.colors.primaryColor, + iconColor = WikipediaTheme.colors.primaryColor, + selectedLeadingIconColor = WikipediaTheme.colors.progressiveColor, + selectedContainerColor = WikipediaTheme.colors.additionColor, + selectedLabelColor = WikipediaTheme.colors.progressiveColor + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = item.isSelected, + borderColor = WikipediaTheme.colors.borderColor, + selectedBorderColor = Color.Transparent + ) + ) } } } + } - when (articlesState) { - is ArticlesState.Error -> {} - ArticlesState.Loading -> { - Text("Loading articles...") - } - is ArticlesState.Success -> { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - articlesState.articles.forEach { - Text(text = it.displayText) + when (articlesState) { + is ArticlesState.Error -> {} + ArticlesState.Loading -> { + Text("Loading articles...") + } + is ArticlesState.Success -> { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(140.dp), + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp), + state = listState, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + content = { + items(articlesState.articles) { item -> + ReadingListInterestCard( + modifier = Modifier.animateItem(), + item = item, + isSelected = articlesState.selectedArticles.contains(item), + onItemClick = { onItemClick(item) } + ) + } + item(span = StaggeredGridItemSpan.FullLine) { + Spacer( + modifier = Modifier.height(64.dp) + ) } } - } + ) } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index abb0d937197..072d1a211dd 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -4,17 +4,38 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import org.wikipedia.Constants import org.wikipedia.activity.BaseActivity import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.page.PageTitle +import org.wikipedia.search.SearchActivity class PersonalizationActivity : BaseActivity() { + + private val viewModel: PersonalizationViewModel by viewModels() + + private val searchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == SearchActivity.RESULT_LINK_SUCCESS) { + val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE)!! + viewModel.addArticle(pageTitle) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BaseTheme { PersonalizationScreen( - onSkipClick = { finish() } + viewModel = viewModel, + onSkipClick = { finish() }, + onSearchClick = { + val intent = SearchActivity.newIntent(this, Constants.InvokeSource.INTEREST_SELECTION, null, returnLink = true) + searchLauncher.launch(intent) + } ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt new file mode 100644 index 00000000000..63e95fea97d --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -0,0 +1,98 @@ +package org.wikipedia.onboarding.personalization + +import androidx.core.net.toUri +import kotlinx.coroutines.delay +import org.wikipedia.WikipediaApp +import org.wikipedia.database.AppDatabase +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.page.Namespace +import org.wikipedia.page.PageTitle +import org.wikipedia.readinglist.database.ReadingListPage +import org.wikipedia.util.StringUtil +import kotlin.collections.forEach +import kotlin.collections.orEmpty + +class PersonalizationRepository { + + // TODO: add actual api call if needed otherwise go with static data + suspend fun getTopics(): List { + println("orange loading categories...") + + val categories = listOf( + OnboardingTopic("1", "Science"), + OnboardingTopic("2", "History"), + OnboardingTopic("3", "Art") + ) + return categories + } + + // TODO: add actual api call + suspend fun getArticlesBytTopic(topics: List): List { + println("orange loading articles for topics $topics...") + delay(5000) // simulate network delay + val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") + val titles = listOf( + PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), + PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), + PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), + PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), + PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), + PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), + PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), + ) + return titles + } + + suspend fun loadInitialArticles(selectedItems: List): List { + println("orange loading initial articles...") + val maxItems = 20 + val results = mutableListOf() + results.addAll(selectedItems) + + if (results.size < maxItems) { + // get most recent history entries + val historyTitles = AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(maxItems, 0) + .map { it.title } + // and a random sampling of reading list pages + val readingListTitles = AppDatabase.instance.readingListPageDao().getPagesByRandom(maxItems) + .map { ReadingListPage.toPageTitle(it) } + // take the two lists and interleave them + for (i in 0 until maxItems) { + if (i < historyTitles.size && !results.contains(historyTitles[i])) results.add(historyTitles[i]) + if (i < readingListTitles.size && !results.contains(readingListTitles[i])) results.add(readingListTitles[i]) + } + // remove non-main namespace articles, or Main page + results.removeAll { it.isMainPage || it.namespace() != Namespace.MAIN } + } + + // If there are still VERY few items, include a few random articles. + val maxRandomItems = 6 + if (results.size < maxRandomItems) { + for (i in results.size until maxRandomItems) { + val title = ServiceFactory.getRest(WikipediaApp.instance.wikiSite).getRandomSummary() + .getPageTitle(WikipediaApp.instance.wikiSite) + if (!results.contains(title)) { + results.add(title) + } + } + } + + // Hydrate titles, if necessary + val itemsNeedingCall = results + .filter { it.description.isNullOrEmpty() || it.thumbUrl.isNullOrEmpty() } + .groupBy { it.wikiSite } + itemsNeedingCall.keys.forEach { site -> + val pageList = ServiceFactory.get(site).getInfoByPageIdsOrTitles(titles = itemsNeedingCall[site]?.joinToString("|") { it.prefixedText }) + .query?.pages.orEmpty() + pageList.forEach { page -> + results.find { it.prefixedText == StringUtil.addUnderscores(page.title) }?.let { title -> + title.description = page.description + title.thumbUrl = page.thumbUrl() + } + } + } + + return results.distinctBy { it.prefixedText } + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 4402d1ded59..ffa386f0197 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -1,7 +1,9 @@ package org.wikipedia.onboarding.personalization +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -25,22 +27,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.compose.components.PageIndicator -import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme -import org.wikipedia.theme.Theme // TODO: probably renaming the screen name @Composable fun PersonalizationScreen( modifier: Modifier = Modifier, onSkipClick: () -> Unit, - viewModel: PersonalizationViewModel = viewModel() + onSearchClick: () -> Unit, + viewModel: PersonalizationViewModel ) { val coroutineScope = rememberCoroutineScope() val uiState = viewModel.interestUiState.collectAsState() @@ -80,12 +79,19 @@ fun PersonalizationScreen( 0 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) 1 -> { InterestOnboardingScreen( - categoriesState = uiState.value.categoriesState, + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), + topicsState = uiState.value.topicsState, articlesState = uiState.value.articlesState, - modifier = Modifier, onCategorySelected = { - viewModel.onCategorySelected(it) - } + viewModel.onTopicSelected(it) + }, + onItemClick = { + viewModel.toggleSelection(it) + }, + onSearchClick = onSearchClick ) } 2 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) @@ -148,15 +154,3 @@ fun OnboardingBottomBar( } } } - -@Preview -@Composable -private fun PersonalizationScreenPreview() { - BaseTheme( - currentTheme = Theme.LIGHT - ) { - PersonalizationScreen( - onSkipClick = {} - ) - } -} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index bb8e75086e2..32121966e1e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -3,27 +3,25 @@ package org.wikipedia.onboarding.personalization import org.wikipedia.page.PageTitle // TODO: update the states below as needed as we build out the screen -data class OnboardingCategory( +data class OnboardingTopic( val id: String, - val title: String + val title: String, + val isSelected: Boolean = false ) data class InterestUiState( - val categoriesState: CategoriesState = CategoriesState.Loading, - val articlesState: ArticlesState = ArticlesState.Loading, - val selectedCategory: String? = null, - val selectedArticles: Set = emptySet(), - val selectionCount: Int = 0, + val topicsState: TopicsState = TopicsState.Loading, + val articlesState: ArticlesState = ArticlesState.Loading ) -sealed interface CategoriesState { - data object Loading : CategoriesState - data class Success(val categories: List) : CategoriesState - data class Error(val message: String) : CategoriesState +sealed interface TopicsState { + data object Loading : TopicsState + data class Success(val topics: List) : TopicsState + data class Error(val message: String) : TopicsState } sealed interface ArticlesState { data object Loading : ArticlesState - data class Success(val articles: List) : ArticlesState + data class Success(val articles: List, val selectedArticles: Set) : ArticlesState data class Error(val message: String) : ArticlesState } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 3be4006fb07..117c872f248 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -1,48 +1,49 @@ package org.wikipedia.onboarding.personalization -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import kotlin.collections.plus -// TODO: remove comments once reviewed -// this is a is a raw, flat, internal representation of ALL state -// needed across the personalization flow (interest, feed preference, language screens) +// this is a raw, flat, internal representation of ALL state +// needed across the personalization flow (interest and feed preference) // this enables SINGLE SOURCE OF TRUTH — one place to update, no risk of states going out of sync // DERIVED UI STATES — each screen gets its own UI state derived from a function like toInterestUIState() // instead of maintaining separate StateFlows per screen or one giant combined UI state private data class PersonalizedViewModelState( // Interest screen - val categories: List = emptyList(), - val categoriesLoading: Boolean = false, - val categoriesError: Throwable? = null, - val selectedCategoryId: String? = null, + val topics: List = emptyList(), + val topicsLoading: Boolean = false, + val topicsError: Throwable? = null, val articles: List = emptyList(), val articlesLoading: Boolean = false, val articlesError: Throwable? = null, - val selectedArticleIds: Set = emptySet(), + val selectedArticles: Set = emptySet(), + val selectedTopics: Set = emptySet(), val searchQuery: String = "", // Feed preference screen properties - // Language screen properties ) { fun toInterestUiState(): InterestUiState { + println("orange selected topics = $selectedTopics") return InterestUiState( - categoriesState = when { - categoriesLoading -> CategoriesState.Loading - categoriesError != null -> CategoriesState.Error( - categoriesError.message ?: "Unknown error" + topicsState = when { + topicsLoading -> TopicsState.Loading + topicsError != null -> TopicsState.Error( + topicsError.message ?: "Unknown error" + ) + else -> TopicsState.Success( + topics = topics.map { + it.copy(isSelected = selectedTopics.contains(it.id)) + } ) - - else -> CategoriesState.Success(categories) }, articlesState = when { articlesLoading -> ArticlesState.Loading @@ -50,20 +51,21 @@ private data class PersonalizedViewModelState( articlesError.message ?: "Unknown error" ) - else -> ArticlesState.Success(articles) - }, - selectedCategory = selectedCategoryId, - selectedArticles = selectedArticleIds, - selectionCount = selectedArticleIds.size + else -> ArticlesState.Success( + articles = articles, + selectedArticles = selectedArticles + ) + } ) } // Each screen in the personalization flow would have its own function // fun toFeedPreferenceUiState(): FeedPreferenceUiState { ... } - // fun toLanguageUiState(): LanguageUiState { ... } } -class PersonalizationViewModel : ViewModel() { +class PersonalizationViewModel( + private val repository: PersonalizationRepository = PersonalizationRepository() +) : 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()) @@ -80,67 +82,94 @@ class PersonalizationViewModel : ViewModel() { fun onPageChanged(page: Int) { when (page) { 1 -> { - loadCategories() - loadArticlesForCategory("") + loadTopics() + loadInitialArticles() } } } - // TODO: add actual api call - private fun loadCategories() { + private fun loadTopics() { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> - state.update { it.copy(categoriesLoading = false, categoriesError = throwable) } + state.update { it.copy(topicsLoading = false, topicsError = throwable) } }) { - state.update { it.copy(categoriesLoading = true) } + state.update { it.copy(topicsLoading = true) } val current = state.value - if (current.categories.isNotEmpty()) return@launch - - println("orange loading categories...") - delay(5000) // simulate network delay - val categories = listOf( - OnboardingCategory("1", "Science"), - OnboardingCategory("2", "History"), - OnboardingCategory("3", "Art") - ) - state.update { it.copy(categories = categories, categoriesLoading = false) } + + if (current.topics.isNotEmpty()) { + state.update { it.copy(topics = current.topics, topicsLoading = false) } + return@launch + } + + val topics = repository.getTopics() + + state.update { it.copy(topics = topics, topicsLoading = false) } + } + } + + private fun loadInitialArticles() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } + }) { + val selectedItems = Prefs.recommendedReadingListInterests + val articles = repository.loadInitialArticles(selectedItems) + state.update { it.copy(articles = articles, articlesLoading = false, selectedArticles = selectedItems.toSet()) } } } - // TODO: add actual api call - private fun loadArticlesForCategory(categoryId: String) { + private fun loadArticlesByTopics(topics: List) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { state.update { it.copy(articlesLoading = true) } val current = state.value - if (current.articles.isNotEmpty()) return@launch - println("orange loading articles for category $categoryId...") - delay(5000) // simulate network delay - val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") - val titles = listOf( - PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), - PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), - PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), - PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), - PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), - PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), - PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), - ) + println("orange is articles empty = ${current.articles.isEmpty()}") + + if (current.articles.isNotEmpty()) { + state.update { it.copy(articles = current.articles, articlesLoading = false) } + return@launch + } + + val titles = repository.getArticlesBytTopic(topics) state.update { it.copy(articles = titles, articlesLoading = false) } } } // as we have a single state it becomes easier to update and control the state - fun onCategorySelected(category: OnboardingCategory) { + fun onTopicSelected(category: OnboardingTopic) { // When a category is selected, we want to reset the articles state and load articles for the selected category + val selectedTopics = if (state.value.selectedTopics.contains(category.id)) { + state.value.selectedTopics - category.id + } else { + state.value.selectedTopics + category.id + } state.update { it.copy( - selectedCategoryId = category.id, + selectedTopics = selectedTopics, articles = emptyList(), articlesLoading = true, articlesError = null ) } - loadArticlesForCategory(category.id) + + loadArticlesByTopics(topics = selectedTopics.toList()) + } + + fun addArticle(title: PageTitle) { + state.update { + val newItems = listOf(title) + it.articles + val newSelection = it.selectedArticles + title + it.copy(articles = newItems, selectedArticles = newSelection) + } + } + + fun toggleSelection(title: PageTitle) { + state.update { + val newSelection = if (it.selectedArticles.contains(title)) { + it.selectedArticles - title + } else { + it.selectedArticles + title + } + it.copy(selectedArticles = newSelection) + } } } From 96884e0343253b788e2e17062811cbdc56688d28 Mon Sep 17 00:00:00 2001 From: williamrai Date: Tue, 31 Mar 2026 09:41:54 -0400 Subject: [PATCH 07/39] - adds shimmer effect --- .../InterestOnboardingScreen.kt | 46 +++++++++++++++++-- .../PersonalizationRepository.kt | 2 +- .../PersonalizationViewModel.kt | 1 + 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index aee3d494bcd..4e4b42d7090 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -1,14 +1,16 @@ package org.wikipedia.onboarding.personalization import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid @@ -16,19 +18,23 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.wikipedia.R +import org.wikipedia.compose.extensions.shimmerEffect import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.recommended.ReadingListInterestCard @@ -45,6 +51,7 @@ fun InterestOnboardingScreen( onSearchClick: () -> Unit ) { val listState = rememberLazyStaggeredGridState() + val transition = rememberInfiniteTransition(label = "shimmerTransition") Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp) @@ -63,7 +70,20 @@ fun InterestOnboardingScreen( when (topicsState) { is TopicsState.Error -> {} TopicsState.Loading -> { - Text("Loading categories...") + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(5) { index -> + val width = remember { listOf(80, 100, 70, 90, 85)[index].dp } + Box( + modifier = Modifier + .width(width) + .height(32.dp) + .clip(RoundedCornerShape(size = 8.dp)) + .shimmerEffect(transition = transition) + ) + } + } } is TopicsState.Success -> { LazyRow( @@ -114,14 +134,30 @@ fun InterestOnboardingScreen( when (articlesState) { is ArticlesState.Error -> {} ArticlesState.Loading -> { - Text("Loading articles...") + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(140.dp), + modifier = Modifier + .fillMaxSize(), + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + content = { + items(10) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(size = 16.dp)) + .shimmerEffect(transition = transition) + ) + } + } + ) } is ArticlesState.Success -> { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(140.dp), modifier = Modifier - .fillMaxSize() - .padding(start = 16.dp, end = 16.dp), + .fillMaxSize(), state = listState, verticalItemSpacing = 16.dp, horizontalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index 63e95fea97d..411999e609c 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -18,7 +18,7 @@ class PersonalizationRepository { // TODO: add actual api call if needed otherwise go with static data suspend fun getTopics(): List { println("orange loading categories...") - + delay(1000) // simulate network delay val categories = listOf( OnboardingTopic("1", "Science"), OnboardingTopic("2", "History"), diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 117c872f248..5bb18ab58de 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -110,6 +110,7 @@ class PersonalizationViewModel( viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { + state.update { it.copy(articlesLoading = true) } val selectedItems = Prefs.recommendedReadingListInterests val articles = repository.loadInitialArticles(selectedItems) state.update { it.copy(articles = articles, articlesLoading = false, selectedArticles = selectedItems.toSet()) } From a5d0f5a7b0b422e3e7951f82e9486fbd1fafbdd0 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 3 Apr 2026 12:04:31 -0400 Subject: [PATCH 08/39] - adds onboarding topics data and message api call --- .../dataclient/mwapi/MwQueryResult.kt | 1 + .../InterestOnboardingScreen.kt | 2 +- .../personalization/OnboardingTopics.kt | 140 ++++++++++++++++++ .../PersonalizationRepository.kt | 20 +-- .../personalization/PersonalizationState.kt | 6 +- .../PersonalizationViewModel.kt | 25 ++-- 6 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt index 59e3791c245..ef936207d97 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt @@ -264,6 +264,7 @@ class MwQueryResult { class Message { val name: String = "" val content: String = "" + val missing: Boolean = false } @Serializable diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 4e4b42d7090..3d89a4d01a7 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -92,7 +92,7 @@ fun InterestOnboardingScreen( items(topicsState.topics) { item -> FilterChip( modifier = Modifier.fillMaxWidth(), - label = { Text(item.title) }, + label = { Text(item.displayTitle) }, selected = item.isSelected, onClick = { onCategorySelected(item) }, leadingIcon = { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt new file mode 100644 index 00000000000..c710201127f --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt @@ -0,0 +1,140 @@ +package org.wikipedia.onboarding.personalization + +object OnboardingTopics { + // Linguistics and Media is not in the filter registry + // americas cannot be queried + val all = listOf( + OnboardingTopic( + topicId = "biography", + msgKey = "wikimedia-articletopics-topic-biography", + articleTopics = "biography", + displayTitle = "Biography" + ), + OnboardingTopic( + topicId = "food-and-drink", + msgKey = "wikimedia-articletopics-topic-food-and-drink", + articleTopics = "food-and-drink", + displayTitle = "Food and Drink" + ), + OnboardingTopic( + topicId = "computers-and-internet", // registry uses "computers-and-internet" as topicId but articleTopics = "internet-culture" + msgKey = "wikimedia-articletopics-topic-computers-and-internet", + articleTopics = "internet-culture", + displayTitle = "Internet Culture" + ), + OnboardingTopic( + topicId = "history", + msgKey = "wikimedia-articletopics-topic-history", + articleTopics = "history", + displayTitle = "History" + ), + OnboardingTopic( + topicId = "literature", + msgKey = "wikimedia-articletopics-topic-literature", + articleTopics = "books", // registry uses "literature" as topicId but articleTopics = "books" + displayTitle = "Literature" + ), + OnboardingTopic( + topicId = "performing-arts", + msgKey = "wikimedia-articletopics-topic-performing-arts", + articleTopics = "performing-arts", + displayTitle = "Performing arts" + ), + OnboardingTopic( + topicId = "philosophy-and-religion", + msgKey = "wikimedia-articletopics-topic-philosophy-and-religion", + articleTopics = "philosophy-and-religion", + displayTitle = "Philosophy and religion" + ), + OnboardingTopic( + topicId = "sports", + msgKey = "wikimedia-articletopics-topic-sports", + articleTopics = "sports", + displayTitle = "Sports" + ), + OnboardingTopic( + topicId = "art", // registry uses "art" as topicId but articleTopics = "visual-arts" + msgKey = "wikimedia-articletopics-topic-art", + articleTopics = "visual-arts", + displayTitle = "Art" + ), + OnboardingTopic( + topicId = "earth-and-environment", // registry uses "earth-and-environment" as topicId but articleTopics = "geographical" + msgKey = "wikimedia-articletopics-topic-earth-and-environment", + articleTopics = "geographical", + displayTitle = "Geographical" + ), + OnboardingTopic( + topicId = "africa", + msgKey = "wikimedia-articletopics-topic-africa", + articleTopics = "africa", + displayTitle = "Africa" + ), + OnboardingTopic( + topicId = "asia", + msgKey = "wikimedia-articletopics-topic-asia", + articleTopics = "asia", + displayTitle = "Asia" + ), + OnboardingTopic( + topicId = "europe", + msgKey = "wikimedia-articletopics-topic-europe", + articleTopics = "europe", + displayTitle = "Europe" + ), + OnboardingTopic( + topicId = "oceania", + msgKey = "wikimedia-articletopics-topic-oceania", + articleTopics = "oceania", + displayTitle = "Oceania" + ), + OnboardingTopic( + topicId = "business-and-economics", + msgKey = "wikimedia-articletopics-topic-business-and-economics", + articleTopics = "business-and-economics", + displayTitle = "Business and economics" + ), + OnboardingTopic( + topicId = "education", + msgKey = "wikimedia-articletopics-topic-education", + articleTopics = "education", + displayTitle = "Education" + ), + OnboardingTopic( + topicId = "history", + msgKey = "wikimedia-articletopics-topic-history", + articleTopics = "history", + displayTitle = "History" + ), + OnboardingTopic( + topicId = "military-and-warfare", + msgKey = "wikimedia-articletopics-topic-military-and-warfare", + articleTopics = "military-and-warfare", + displayTitle = "Military and warfare" + ), + OnboardingTopic( + topicId = "politics-and-government", + msgKey = "wikimedia-articletopics-topic-politics-and-government", + articleTopics = "politics-and-government", + displayTitle = "Politics and government" + ), + OnboardingTopic( + topicId = "society", + msgKey = "wikimedia-articletopics-topic-society", + articleTopics = "society", + displayTitle = "Society" + ), + OnboardingTopic( + topicId = "transportation", + msgKey = "wikimedia-articletopics-topic-transportation", + articleTopics = "transportation", + displayTitle = "Transportation" + ), + OnboardingTopic( + topicId = "general-science", // registry uses "general-science" as topicId but articleTopics = "stem" + msgKey = "wikimedia-articletopics-topic-general-science", + articleTopics = "stem", + displayTitle = "STEM" + ), + ) +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index 411999e609c..e84b70715a9 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -16,15 +16,17 @@ import kotlin.collections.orEmpty class PersonalizationRepository { // TODO: add actual api call if needed otherwise go with static data - suspend fun getTopics(): List { - println("orange loading categories...") - delay(1000) // simulate network delay - val categories = listOf( - OnboardingTopic("1", "Science"), - OnboardingTopic("2", "History"), - OnboardingTopic("3", "Art") - ) - return categories + suspend fun getTopics(langCode: String): List { + val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getMessages(messages = allMsgKey, args = null, lang = langCode) + val translations = response.query?.allmessages + ?.filterNot { it.missing } + ?.associate { it.name to it.content } + .orEmpty() + + return OnboardingTopics.all.map { topic -> + topic.copy(displayTitle = translations[topic.msgKey] ?: topic.displayTitle) + } } // TODO: add actual api call diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 32121966e1e..9f84d0175b4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -4,8 +4,10 @@ import org.wikipedia.page.PageTitle // TODO: update the states below as needed as we build out the screen data class OnboardingTopic( - val id: String, - val title: String, + val topicId: String, + val msgKey: String, + val articleTopics: String, + val displayTitle: String, val isSelected: Boolean = false ) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 5bb18ab58de..100462bae92 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.wikipedia.WikipediaApp import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs import kotlin.collections.plus @@ -32,7 +33,6 @@ private data class PersonalizedViewModelState( // Feed preference screen properties ) { fun toInterestUiState(): InterestUiState { - println("orange selected topics = $selectedTopics") return InterestUiState( topicsState = when { topicsLoading -> TopicsState.Loading @@ -41,7 +41,7 @@ private data class PersonalizedViewModelState( ) else -> TopicsState.Success( topics = topics.map { - it.copy(isSelected = selectedTopics.contains(it.id)) + it.copy(isSelected = selectedTopics.contains(it.topicId)) } ) }, @@ -68,6 +68,7 @@ class PersonalizationViewModel( ) : 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()) + private val topicApiLookUp = OnboardingTopics.all.associate { it.topicId to it.articleTopics } // Each screen observes only its own derived UI state // runs automatically when any part of the raw state changes @@ -82,13 +83,14 @@ class PersonalizationViewModel( fun onPageChanged(page: Int) { when (page) { 1 -> { - loadTopics() + val langCode = WikipediaApp.instance.languageState.appLanguageCode + loadTopics(langCode) loadInitialArticles() } } } - private fun loadTopics() { + private fun loadTopics(langCode: String) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(topicsLoading = false, topicsError = throwable) } }) { @@ -100,7 +102,7 @@ class PersonalizationViewModel( return@launch } - val topics = repository.getTopics() + val topics = repository.getTopics(langCode) state.update { it.copy(topics = topics, topicsLoading = false) } } @@ -123,7 +125,6 @@ class PersonalizationViewModel( }) { state.update { it.copy(articlesLoading = true) } val current = state.value - println("orange is articles empty = ${current.articles.isEmpty()}") if (current.articles.isNotEmpty()) { state.update { it.copy(articles = current.articles, articlesLoading = false) } @@ -136,13 +137,14 @@ class PersonalizationViewModel( } // as we have a single state it becomes easier to update and control the state - fun onTopicSelected(category: OnboardingTopic) { + fun onTopicSelected(topic: OnboardingTopic) { // When a category is selected, we want to reset the articles state and load articles for the selected category - val selectedTopics = if (state.value.selectedTopics.contains(category.id)) { - state.value.selectedTopics - category.id + val selectedTopics = if (state.value.selectedTopics.contains(topic.topicId)) { + state.value.selectedTopics - topic.topicId } else { - state.value.selectedTopics + category.id + state.value.selectedTopics + topic.topicId } + state.update { it.copy( selectedTopics = selectedTopics, @@ -152,7 +154,8 @@ class PersonalizationViewModel( ) } - loadArticlesByTopics(topics = selectedTopics.toList()) + val topicQueryIds = selectedTopics.mapNotNull { topicApiLookUp[it] } + loadArticlesByTopics(topics = topicQueryIds.toList()) } fun addArticle(title: PageTitle) { From 2ad35ce98a9297655fc28af820feb321280f2b25 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 3 Apr 2026 15:08:47 -0400 Subject: [PATCH 09/39] - adds bottom selection bar - moves view to its own composable functions - adds string resource - code fixes --- .../InterestOnboardingScreen.kt | 238 +++++++++++++----- .../personalization/PersonalizationScreen.kt | 9 +- .../PersonalizationViewModel.kt | 17 ++ ...RecommendedReadingListInterestsFragment.kt | 9 +- app/src/main/res/values-qq/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 203 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 3d89a4d01a7..a9563fe155c 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -2,13 +2,17 @@ package org.wikipedia.onboarding.personalization import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow @@ -19,13 +23,17 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -46,17 +54,19 @@ fun InterestOnboardingScreen( modifier: Modifier = Modifier, topicsState: TopicsState, articlesState: ArticlesState, - onCategorySelected: (OnboardingTopic) -> Unit, + onTopicSelected: (OnboardingTopic) -> Unit, onItemClick: (PageTitle) -> Unit = {}, - onSearchClick: () -> Unit + onSearchClick: () -> Unit, + onDeselectAllClick: () -> Unit ) { val listState = rememberLazyStaggeredGridState() val transition = rememberInfiniteTransition(label = "shimmerTransition") Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( + modifier = Modifier.padding(horizontal = 16.dp), text = stringResource(id = R.string.recommended_reading_list_interest_pick_title), style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.Medium @@ -64,6 +74,7 @@ fun InterestOnboardingScreen( ) ReadingListInterestSearchCard( + modifier = Modifier.padding(horizontal = 16.dp), onSearchClick = onSearchClick ) @@ -71,7 +82,8 @@ fun InterestOnboardingScreen( is TopicsState.Error -> {} TopicsState.Loading -> { LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items(5) { index -> val width = remember { listOf(80, 100, 70, 90, 85)[index].dp } @@ -85,49 +97,12 @@ fun InterestOnboardingScreen( } } } + is TopicsState.Success -> { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(topicsState.topics) { item -> - FilterChip( - modifier = Modifier.fillMaxWidth(), - label = { Text(item.displayTitle) }, - selected = item.isSelected, - onClick = { onCategorySelected(item) }, - leadingIcon = { - AnimatedContent( - targetState = item.isSelected - ) { isSelected -> - Icon( - modifier = Modifier - .size(16.dp), - painter = if (isSelected) { - R.drawable.ic_check_black_24dp - } else { - R.drawable.ic_add_gray_white_24dp - }.let { painterResource(id = it) }, - contentDescription = null - ) - } - }, - colors = FilterChipDefaults.filterChipColors( - containerColor = WikipediaTheme.colors.backgroundColor, - labelColor = WikipediaTheme.colors.primaryColor, - iconColor = WikipediaTheme.colors.primaryColor, - selectedLeadingIconColor = WikipediaTheme.colors.progressiveColor, - selectedContainerColor = WikipediaTheme.colors.additionColor, - selectedLabelColor = WikipediaTheme.colors.progressiveColor - ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, - selected = item.isSelected, - borderColor = WikipediaTheme.colors.borderColor, - selectedBorderColor = Color.Transparent - ) - ) - } - } + TopicFilterChipRow( + topics = topicsState.topics, + onTopicSelected = { onTopicSelected(it) } + ) } } @@ -140,6 +115,7 @@ fun InterestOnboardingScreen( .fillMaxSize(), verticalItemSpacing = 16.dp, horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(16.dp), content = { items(10) { Box( @@ -153,30 +129,160 @@ fun InterestOnboardingScreen( } ) } + is ArticlesState.Success -> { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(140.dp), - modifier = Modifier - .fillMaxSize(), - state = listState, - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - content = { - items(articlesState.articles) { item -> - ReadingListInterestCard( - modifier = Modifier.animateItem(), - item = item, - isSelected = articlesState.selectedArticles.contains(item), - onItemClick = { onItemClick(item) } - ) - } - item(span = StaggeredGridItemSpan.FullLine) { - Spacer( - modifier = Modifier.height(64.dp) - ) + Box { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(140.dp), + modifier = Modifier + .fillMaxSize(), + state = listState, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(16.dp), + content = { + items(articlesState.articles) { item -> + ReadingListInterestCard( + modifier = Modifier.animateItem(), + item = item, + isSelected = articlesState.selectedArticles.contains(item), + onItemClick = { onItemClick(item) } + ) + } + item(span = StaggeredGridItemSpan.FullLine) { + Spacer( + modifier = Modifier.height(64.dp) + ) + } } + ) + SelectionBottomBar( + modifier = Modifier + .align(Alignment.BottomStart) + .background(WikipediaTheme.colors.paperColor), + selectedItemsCount = articlesState.selectedArticles.size, + onDeselectAllClick = onDeselectAllClick + ) + } + } + } + } +} + +@Composable +fun TopicFilterChipRow( + topics: List, + modifier: Modifier = Modifier, + onTopicSelected: (OnboardingTopic) -> Unit +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(items = topics, key = { it.topicId }) { item -> + FilterChip( + modifier = Modifier.fillMaxWidth(), + label = { Text(item.displayTitle) }, + selected = item.isSelected, + onClick = { onTopicSelected(item) }, + leadingIcon = { + AnimatedContent( + targetState = item.isSelected + ) { isSelected -> + Icon( + modifier = Modifier + .size(16.dp), + painter = if (isSelected) { + R.drawable.ic_check_black_24dp + } else { + R.drawable.ic_add_gray_white_24dp + }.let { painterResource(id = it) }, + contentDescription = null + ) } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = WikipediaTheme.colors.backgroundColor, + labelColor = WikipediaTheme.colors.primaryColor, + iconColor = WikipediaTheme.colors.primaryColor, + selectedLeadingIconColor = WikipediaTheme.colors.progressiveColor, + selectedContainerColor = WikipediaTheme.colors.additionColor, + selectedLabelColor = WikipediaTheme.colors.progressiveColor + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = item.isSelected, + borderColor = WikipediaTheme.colors.borderColor, + selectedBorderColor = Color.Transparent ) + ) + } + } +} + +@Composable +fun SelectionBottomBar( + selectedItemsCount: Int, + modifier: Modifier = Modifier, + onDeselectAllClick: () -> Unit +) { + Column( + modifier = modifier + ) { + HorizontalDivider( + thickness = 0.5.dp, + color = WikipediaTheme.colors.borderColor + ) + AnimatedContent( + targetState = selectedItemsCount > 0 + ) { isSelected -> + if (isSelected) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string.multi_select_items_selected, + selectedItemsCount + ), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold + ) + ) + + Button( + onClick = onDeselectAllClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WikipediaTheme.colors.backgroundColor, + contentColor = WikipediaTheme.colors.secondaryColor + ) + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(size = 8.dp)), + text = stringResource(R.string.explore_feed_deselect_all_btn_label), + style = MaterialTheme.typography.labelLarge + ) + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.recommended_reading_list_interest_select_minimum), + style = MaterialTheme.typography.labelLarge, + ) + } } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index ffa386f0197..1760fbf4d3c 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -82,16 +82,19 @@ fun PersonalizationScreen( modifier = Modifier .fillMaxSize() .background(WikipediaTheme.colors.paperColor) - .padding(top = 40.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), + .padding(top = 40.dp), topicsState = uiState.value.topicsState, articlesState = uiState.value.articlesState, - onCategorySelected = { + onTopicSelected = { viewModel.onTopicSelected(it) }, onItemClick = { viewModel.toggleSelection(it) }, - onSearchClick = onSearchClick + onSearchClick = onSearchClick, + onDeselectAllClick = { + viewModel.deselectAllArticles() + } ) } 2 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 100462bae92..114da7c917e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -113,6 +113,13 @@ class PersonalizationViewModel( state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { state.update { it.copy(articlesLoading = true) } + val current = state.value + + if (current.articles.isNotEmpty()) { + state.update { it.copy(articles = current.articles, articlesLoading = false) } + return@launch + } + val selectedItems = Prefs.recommendedReadingListInterests val articles = repository.loadInitialArticles(selectedItems) state.update { it.copy(articles = articles, articlesLoading = false, selectedArticles = selectedItems.toSet()) } @@ -176,4 +183,14 @@ class PersonalizationViewModel( it.copy(selectedArticles = newSelection) } } + + fun deselectAllArticles() { + state.update { + it.copy( + selectedArticles = emptySet(), + articlesLoading = false, + articlesError = null + ) + } + } } diff --git a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt index 55e7a3bcde5..017f952b757 100644 --- a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt @@ -362,7 +362,7 @@ fun RecommendedReadingListInterestsContent( ) } item(span = StaggeredGridItemSpan.FullLine) { - ReadingListInterestSearchCard(onSearchClick) + ReadingListInterestSearchCard(onSearchClick = onSearchClick) } items(items) { item -> ReadingListInterestCard( @@ -519,9 +519,12 @@ fun ReadingListInterestCard( } @Composable -fun ReadingListInterestSearchCard(onSearchClick: () -> Unit) { +fun ReadingListInterestSearchCard( + modifier: Modifier = Modifier, + onSearchClick: () -> Unit +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(56.dp) .clip(RoundedCornerShape(28.dp)) diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 0ae780eb79c..bfd5fb32ecd 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2218,4 +2218,5 @@ Body text for the feed building screen. Display name for the title of the Explore feed onboarding screen prompting users to follow topics. Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. + Button text to deselect all selected items in the intereset selection screen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45db643d91c..4213e6ab45a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2471,4 +2471,5 @@ Let\'s build your feed… Follow your curiosity Select topics that interest you and we will personalize your feed.\n\nWe collect minimal data that is anonymized. + Deselect all \ No newline at end of file From 67271664ee34bd69462d38fdd7e2e61ebdba0a5b Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 3 Apr 2026 15:41:40 -0400 Subject: [PATCH 10/39] - dose not replace selected articles --- .../onboarding/personalization/PersonalizationViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 114da7c917e..b47c1096e68 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -138,8 +138,9 @@ class PersonalizationViewModel( return@launch } - val titles = repository.getArticlesBytTopic(topics) - state.update { it.copy(articles = titles, articlesLoading = false) } + val articles = repository.getArticlesBytTopic(topics) + val newArticles = (current.selectedArticles.toList() + articles).distinct() + state.update { it.copy(articles = newArticles, articlesLoading = false) } } } From c18c051e95cbfc7d96b09874211d931488d4d0d9 Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 6 Apr 2026 14:57:53 -0400 Subject: [PATCH 11/39] - adds proper api call to getArticlesByTopic - adds topics - code fixes --- .../java/org/wikipedia/dataclient/Service.kt | 7 ++ .../personalization/OnboardingTopics.kt | 114 +++++++++++++++++- .../PersonalizationRepository.kt | 39 +++--- .../PersonalizationViewModel.kt | 6 +- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index 2864cab1180..3329165cada 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -742,6 +742,13 @@ interface Service { @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&inprop=varianttitles") suspend fun getVariantTitlesByTitles(@Query("titles") titles: String): MwQueryResponse + @GET(MW_API_PREFIX + "action=query&generator=search&redirects=&converttitles=&prop=description|pageimages|info&piprop=thumbnail" + + "&pilicense=any&gpsnamespace=0&inprop=varianttitles|displaytitle&pithumbsize=" + PREFERRED_THUMB_SIZE) + suspend fun getArticlesByTopic( + @Query("gsrsearch") articleTopics: String, + @Query("gsrlimit") limit: Int + ): MwQueryResponse + companion object { const val WIKIPEDIA_URL = "https://${WikiSite.BASE_DOMAIN}/" const val BASE_AUTHORITY_WIKIMEDIA = "wikimedia.org" diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt index c710201127f..33c431c3f05 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt @@ -100,12 +100,6 @@ object OnboardingTopics { articleTopics = "education", displayTitle = "Education" ), - OnboardingTopic( - topicId = "history", - msgKey = "wikimedia-articletopics-topic-history", - articleTopics = "history", - displayTitle = "History" - ), OnboardingTopic( topicId = "military-and-warfare", msgKey = "wikimedia-articletopics-topic-military-and-warfare", @@ -136,5 +130,113 @@ object OnboardingTopics { articleTopics = "stem", displayTitle = "STEM" ), + OnboardingTopic( + topicId = "central-america", + msgKey = "wikimedia-articletopics-topic-central-america", + articleTopics = "central-america", + displayTitle = "Central America" + ), + OnboardingTopic( + topicId = "north-america", + msgKey = "wikimedia-articletopics-topic-north-america", + articleTopics = "north-america", + displayTitle = "North America" + ), + OnboardingTopic( + topicId = "south-america", + msgKey = "wikimedia-articletopics-topic-south-america", + articleTopics = "south-america", + displayTitle = "South America" + ), + OnboardingTopic( + topicId = "architecture", + msgKey = "wikimedia-articletopics-topic-architecture", + articleTopics = "architecture", + displayTitle = "Architecture" + ), + OnboardingTopic( + topicId = "comics-and-anime", + msgKey = "wikimedia-articletopics-topic-comics-and-anime", + articleTopics = "comics-and-anime", + displayTitle = "Comics and Anime" + ), + OnboardingTopic( + topicId = "entertainment", + msgKey = "wikimedia-articletopics-topic-entertainment", + articleTopics = "entertainment", + displayTitle = "Entertainment" + ), + OnboardingTopic( + topicId = "fashion", + msgKey = "wikimedia-articletopics-topic-fashion", + articleTopics = "fashion", + displayTitle = "Fashion" + ), + OnboardingTopic( + topicId = "music", + msgKey = "wikimedia-articletopics-topic-music", + articleTopics = "music", + displayTitle = "Music" + ), + OnboardingTopic( + topicId = "tv-and-film", + msgKey = "wikimedia-articletopics-topic-tv-and-film", + articleTopics = "films", + displayTitle = "TV and Film" + ), + OnboardingTopic( + topicId = "video-games", + msgKey = "wikimedia-articletopics-topic-video-games", + articleTopics = "video-games", + displayTitle = "Video Games" + ), + OnboardingTopic( + topicId = "women", + msgKey = "wikimedia-articletopics-topic-women", + articleTopics = "women", + displayTitle = "Women" + ), + OnboardingTopic( + topicId = "biology", + msgKey = "wikimedia-articletopics-topic-biology", + articleTopics = "biology", + displayTitle = "Biology" + ), + OnboardingTopic( + topicId = "chemistry", + msgKey = "wikimedia-articletopics-topic-chemistry", + articleTopics = "chemistry", + displayTitle = "Chemistry" + ), + OnboardingTopic( + topicId = "engineering", + msgKey = "wikimedia-articletopics-topic-engineering", + articleTopics = "engineering", + displayTitle = "Engineering" + ), + OnboardingTopic( + topicId = "mathematics", + msgKey = "wikimedia-articletopics-topic-mathematics", + articleTopics = "mathematics", + displayTitle = "Mathematics" + ), + OnboardingTopic( + topicId = "medicine-and-health", + msgKey = "wikimedia-articletopics-topic-medicine-and-health", + articleTopics = "medicine-and-health", + displayTitle = "Medicine and Health" + ), + OnboardingTopic( + topicId = "physics", + msgKey = "wikimedia-articletopics-topic-physics", + articleTopics = "physics", + displayTitle = "Physics" + ), + OnboardingTopic( + topicId = "technology", + msgKey = "wikimedia-articletopics-topic-technology", + articleTopics = "technology", + displayTitle = "Technology" + ), ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index e84b70715a9..a94f34f2f82 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -1,21 +1,15 @@ package org.wikipedia.onboarding.personalization -import androidx.core.net.toUri -import kotlinx.coroutines.delay import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.StringUtil -import kotlin.collections.forEach -import kotlin.collections.orEmpty class PersonalizationRepository { - // TODO: add actual api call if needed otherwise go with static data suspend fun getTopics(langCode: String): List { val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getMessages(messages = allMsgKey, args = null, lang = langCode) @@ -23,31 +17,28 @@ class PersonalizationRepository { ?.filterNot { it.missing } ?.associate { it.name to it.content } .orEmpty() - return OnboardingTopics.all.map { topic -> - topic.copy(displayTitle = translations[topic.msgKey] ?: topic.displayTitle) + topic.copy(displayTitle = translations[topic.msgKey] ?: "no") } } - // TODO: add actual api call - suspend fun getArticlesBytTopic(topics: List): List { - println("orange loading articles for topics $topics...") - delay(5000) // simulate network delay - val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") - val titles = listOf( - PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), - PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), - PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), - PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), - PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), - PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), - PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), - ) - return titles + suspend fun getArticlesBytTopic(topic: String): List { + val searchTerm = "articletopic:$topic" + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getArticlesByTopic(searchTerm, 25) + val pageList = response.query?.pages + ?.map { page -> + PageTitle( + text = page.title, + wiki = WikipediaApp.instance.wikiSite, + thumbUrl = page.thumbUrl(), + description = page.description, + displayText = page.displayTitle(WikipediaApp.instance.wikiSite.languageCode) + ) + } ?: emptyList() + return pageList } suspend fun loadInitialArticles(selectedItems: List): List { - println("orange loading initial articles...") val maxItems = 20 val results = mutableListOf() results.addAll(selectedItems) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index b47c1096e68..4b403be12ec 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -126,7 +126,7 @@ class PersonalizationViewModel( } } - private fun loadArticlesByTopics(topics: List) { + private fun loadArticlesByTopic(topic: String) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { @@ -138,7 +138,7 @@ class PersonalizationViewModel( return@launch } - val articles = repository.getArticlesBytTopic(topics) + val articles = repository.getArticlesBytTopic(topic) val newArticles = (current.selectedArticles.toList() + articles).distinct() state.update { it.copy(articles = newArticles, articlesLoading = false) } } @@ -163,7 +163,7 @@ class PersonalizationViewModel( } val topicQueryIds = selectedTopics.mapNotNull { topicApiLookUp[it] } - loadArticlesByTopics(topics = topicQueryIds.toList()) + if (topicQueryIds.isEmpty()) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryIds.last()) } fun addArticle(title: PageTitle) { From 06fc0614095a543c5bc633b85cbc9965827d7c1b Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 6 Apr 2026 15:53:46 -0400 Subject: [PATCH 12/39] - updates interest screen ui to be fully scrollable --- .../InterestOnboardingScreen.kt | 171 +++++++++--------- 1 file changed, 84 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index a9563fe155c..1a9093331d2 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan @@ -48,7 +49,6 @@ import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.recommended.ReadingListInterestCard import org.wikipedia.readinglist.recommended.ReadingListInterestSearchCard -// TODO: add actual UI @Composable fun InterestOnboardingScreen( modifier: Modifier = Modifier, @@ -57,90 +57,84 @@ fun InterestOnboardingScreen( onTopicSelected: (OnboardingTopic) -> Unit, onItemClick: (PageTitle) -> Unit = {}, onSearchClick: () -> Unit, - onDeselectAllClick: () -> Unit + onDeselectAllClick: () -> Unit, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState() ) { - val listState = rememberLazyStaggeredGridState() val transition = rememberInfiniteTransition(label = "shimmerTransition") - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(id = R.string.recommended_reading_list_interest_pick_title), - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Medium + Box(modifier = modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.recommended_reading_list_interest_pick_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.primaryColor ) - ) - - ReadingListInterestSearchCard( - modifier = Modifier.padding(horizontal = 16.dp), - onSearchClick = onSearchClick - ) - when (topicsState) { - is TopicsState.Error -> {} - TopicsState.Loading -> { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp) - ) { - items(5) { index -> - val width = remember { listOf(80, 100, 70, 90, 85)[index].dp } - Box( - modifier = Modifier - .width(width) - .height(32.dp) - .clip(RoundedCornerShape(size = 8.dp)) - .shimmerEffect(transition = transition) + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(140.dp), + modifier = Modifier + .fillMaxSize(), + state = gridState, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(16.dp), + content = { + item(span = StaggeredGridItemSpan.FullLine) { + ReadingListInterestSearchCard( + onSearchClick = onSearchClick ) } - } - } - - is TopicsState.Success -> { - TopicFilterChipRow( - topics = topicsState.topics, - onTopicSelected = { onTopicSelected(it) } - ) - } - } + item(span = StaggeredGridItemSpan.FullLine) { + when (topicsState) { + is TopicsState.Error -> {} + TopicsState.Loading -> { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(5) { index -> + val width = + remember { listOf(80, 100, 70, 90, 85)[index].dp } + Box( + modifier = Modifier + .width(width) + .height(32.dp) + .clip(RoundedCornerShape(size = 8.dp)) + .shimmerEffect(transition = transition) + ) + } + } + } - when (articlesState) { - is ArticlesState.Error -> {} - ArticlesState.Loading -> { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(140.dp), - modifier = Modifier - .fillMaxSize(), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(16.dp), - content = { - items(10) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(size = 16.dp)) - .shimmerEffect(transition = transition) - ) + is TopicsState.Success -> { + TopicFilterChipRow( + topics = topicsState.topics, + onTopicSelected = { onTopicSelected(it) } + ) + } } } - ) - } - is ArticlesState.Success -> { - Box { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(140.dp), - modifier = Modifier - .fillMaxSize(), - state = listState, - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(16.dp), - content = { + when (articlesState) { + is ArticlesState.Error -> {} + ArticlesState.Loading -> { + items(10) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(size = 16.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + is ArticlesState.Success -> { items(articlesState.articles) { item -> ReadingListInterestCard( modifier = Modifier.animateItem(), @@ -155,16 +149,18 @@ fun InterestOnboardingScreen( ) } } - ) - SelectionBottomBar( - modifier = Modifier - .align(Alignment.BottomStart) - .background(WikipediaTheme.colors.paperColor), - selectedItemsCount = articlesState.selectedArticles.size, - onDeselectAllClick = onDeselectAllClick - ) + } } - } + ) + } + if (articlesState is ArticlesState.Success) { + SelectionBottomBar( + modifier = Modifier + .align(Alignment.BottomStart) + .background(WikipediaTheme.colors.paperColor), + selectedItemsCount = articlesState.selectedArticles.size, + onDeselectAllClick = onDeselectAllClick + ) } } } @@ -177,8 +173,7 @@ fun TopicFilterChipRow( ) { LazyRow( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(items = topics, key = { it.topicId }) { item -> FilterChip( @@ -251,7 +246,8 @@ fun SelectionBottomBar( selectedItemsCount ), style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = WikipediaTheme.colors.primaryColor ) ) @@ -281,6 +277,7 @@ fun SelectionBottomBar( Text( text = stringResource(R.string.recommended_reading_list_interest_select_minimum), style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.primaryColor ) } } From 492765432fef737ea12031611824985f288d0d67 Mon Sep 17 00:00:00 2001 From: williamrai Date: Tue, 7 Apr 2026 10:54:13 -0400 Subject: [PATCH 13/39] - adds interest entity, dao, and viewmodel logic - code fixes --- .../32.json | 62 ++++++++++++- .../org/wikipedia/database/AppDatabase.kt | 25 ++++- .../InterestOnboardingScreen.kt | 23 ++++- .../PersonalizationActivity.kt | 2 +- .../PersonalizationRepository.kt | 41 ++++++++- .../personalization/PersonalizationScreen.kt | 3 + .../personalization/PersonalizationState.kt | 2 +- .../PersonalizationViewModel.kt | 91 ++++++++++++++----- .../personalization/db/dao/InterestDao.kt | 27 ++++++ .../personalization/db/entity/Interest.kt | 26 ++++++ 10 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt diff --git a/app/schemas/org.wikipedia.database.AppDatabase/32.json b/app/schemas/org.wikipedia.database.AppDatabase/32.json index b745e638b7f..df27b20408c 100644 --- a/app/schemas/org.wikipedia.database.AppDatabase/32.json +++ b/app/schemas/org.wikipedia.database.AppDatabase/32.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 32, - "identityHash": "13caecc46590fb1cbfddc84dfcd32263", + "identityHash": "af66b2aaebea2bd3fb1dc8d3fa542914", "entities": [ { "tableName": "HistoryEntry", @@ -742,11 +742,69 @@ "id" ] } + }, + { + "tableName": "Interests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `topicLabel` TEXT NOT NULL, `topicKey` TEXT NOT NULL, `lang` TEXT NOT NULL, `articleApiTitle` TEXT NOT NULL, `articleDisplayTitle` TEXT NOT NULL, `articleDescription` TEXT, `articleThumbUrl` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "topicLabel", + "columnName": "topicLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicKey", + "columnName": "topicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleApiTitle", + "columnName": "articleApiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleDisplayTitle", + "columnName": "articleDisplayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleDescription", + "columnName": "articleDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "articleThumbUrl", + "columnName": "articleThumbUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '13caecc46590fb1cbfddc84dfcd32263')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'af66b2aaebea2bd3fb1dc8d3fa542914')" ] } } \ No newline at end of file diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index 5eac0d10b1c..b24f6c174f1 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -21,6 +21,8 @@ import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao import org.wikipedia.offline.db.OfflineObject import org.wikipedia.offline.db.OfflineObjectDao +import org.wikipedia.onboarding.personalization.db.dao.InterestDao +import org.wikipedia.onboarding.personalization.db.entity.Interest import org.wikipedia.pageimages.db.PageImage import org.wikipedia.pageimages.db.PageImageDao import org.wikipedia.readinglist.database.ReadingList @@ -39,7 +41,7 @@ import org.wikipedia.talk.db.TalkTemplateDao import java.time.LocalDate const val DATABASE_NAME = "wikipedia.db" -const val DATABASE_VERSION = 32 +const val DATABASE_VERSION = 33 @Database( entities = [ @@ -55,7 +57,8 @@ const val DATABASE_VERSION = 32 TalkTemplate::class, Category::class, DailyGameHistory::class, - RecommendedPage::class + RecommendedPage::class, + Interest::class ], version = DATABASE_VERSION ) @@ -81,6 +84,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun dailyGameHistoryDao(): DailyGameHistoryDao abstract fun recommendedPageDao(): RecommendedPageDao + abstract fun interestDao(): InterestDao companion object { val MIGRATION_19_20 = object : Migration(19, 20) { @@ -355,13 +359,28 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE DailyGameHistory ADD COLUMN currentQuestionIndex INTEGER NOT NULL DEFAULT ${OnThisDayGameViewModel.MAX_QUESTIONS}") } } + val MIGRATION_32_33 = object : Migration(32, 33) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS Interests (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + "type INTEGER NOT NULL," + + "lang TEXT NOT NULL," + + "topicLabel TEXT," + + "topicKey TEXT," + + "articleApiTitle TEXT," + + "articleDisplayTitle TEXT," + + "articleDescription TEXT," + + "articleThumbUrl TEXT" + + ")") + } + } val instance: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Room.databaseBuilder(WikipediaApp.instance, AppDatabase::class.java, DATABASE_NAME) .addMigrations(MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_26_28, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32) + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33) .fallbackToDestructiveMigration(false) .build() } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 1a9093331d2..8f308e39c66 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.wikipedia.R +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.WikipediaTheme import org.wikipedia.page.PageTitle @@ -58,6 +60,7 @@ fun InterestOnboardingScreen( onItemClick: (PageTitle) -> Unit = {}, onSearchClick: () -> Unit, onDeselectAllClick: () -> Unit, + retryLoading: () -> Unit, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState() ) { val transition = rememberInfiniteTransition(label = "shimmerTransition") @@ -121,7 +124,25 @@ fun InterestOnboardingScreen( } when (articlesState) { - is ArticlesState.Error -> {} + is ArticlesState.Error -> { + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + modifier = Modifier + .fillMaxWidth(), + caught = articlesState.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = retryLoading + ), + retryForGenericError = true + ) + } + } + } ArticlesState.Loading -> { items(10) { Box( diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index 072d1a211dd..61ec504dc5d 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -20,7 +20,7 @@ class PersonalizationActivity : BaseActivity() { private val searchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == SearchActivity.RESULT_LINK_SUCCESS) { val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE)!! - viewModel.addArticle(pageTitle) + viewModel.addArticleFromSearch(pageTitle) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index a94f34f2f82..e49baa48037 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -3,12 +3,15 @@ package org.wikipedia.onboarding.personalization import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.onboarding.personalization.db.dao.InterestDao +import org.wikipedia.onboarding.personalization.db.entity.Interest +import org.wikipedia.onboarding.personalization.db.entity.InterestType import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.StringUtil -class PersonalizationRepository { +class PersonalizationRepository(private val interestDao: InterestDao) { suspend fun getTopics(langCode: String): List { val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } @@ -88,4 +91,40 @@ class PersonalizationRepository { return results.distinctBy { it.prefixedText } } + + suspend fun saveTopic(topic: OnboardingTopic, lang: String) { + interestDao.insert( + Interest( + topicKey = topic.topicId, + topicLabel = topic.displayTitle, + lang = lang, + type = InterestType.TOPIC.value + ) + ) + } + + suspend fun deleteTopic(topic: OnboardingTopic, lang: String) { + interestDao.findTopic(topic.topicId, lang)?.let { + interestDao.delete(it) + } + } + + suspend fun saveArticle(article: PageTitle, lang: String) { + interestDao.insert( + Interest( + type = InterestType.ARTICLE.value, + lang = lang, + articleApiTitle = article.prefixedText, + articleDisplayTitle = article.displayText, + articleDescription = article.description, + articleThumbUrl = article.thumbUrl + ) + ) + } + + suspend fun deleteArticle(article: PageTitle, lang: String) { + interestDao.findArticle(article.prefixedText, lang)?.let { + interestDao.delete(it) + } + } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 1760fbf4d3c..f350627cdb6 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -94,6 +94,9 @@ fun PersonalizationScreen( onSearchClick = onSearchClick, onDeselectAllClick = { viewModel.deselectAllArticles() + }, + retryLoading = { + viewModel.retryLoading() } ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 9f84d0175b4..0e3530a6ebd 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -25,5 +25,5 @@ sealed interface TopicsState { sealed interface ArticlesState { data object Loading : ArticlesState data class Success(val articles: List, val selectedArticles: Set) : ArticlesState - data class Error(val message: String) : ArticlesState + data class Error(val message: Throwable) : ArticlesState } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 4b403be12ec..287f102c473 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp +import org.wikipedia.database.AppDatabase import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs -import kotlin.collections.plus // this is a raw, flat, internal representation of ALL state // needed across the personalization flow (interest and feed preference) @@ -39,6 +39,7 @@ private data class PersonalizedViewModelState( topicsError != null -> TopicsState.Error( topicsError.message ?: "Unknown error" ) + else -> TopicsState.Success( topics = topics.map { it.copy(isSelected = selectedTopics.contains(it.topicId)) @@ -48,7 +49,7 @@ private data class PersonalizedViewModelState( articlesState = when { articlesLoading -> ArticlesState.Loading articlesError != null -> ArticlesState.Error( - articlesError.message ?: "Unknown error" + articlesError ) else -> ArticlesState.Success( @@ -64,7 +65,7 @@ private data class PersonalizedViewModelState( } class PersonalizationViewModel( - private val repository: PersonalizationRepository = PersonalizationRepository() + private val repository: PersonalizationRepository = PersonalizationRepository(AppDatabase.instance.interestDao()) ) : 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()) @@ -122,7 +123,13 @@ class PersonalizationViewModel( val selectedItems = Prefs.recommendedReadingListInterests val articles = repository.loadInitialArticles(selectedItems) - state.update { it.copy(articles = articles, articlesLoading = false, selectedArticles = selectedItems.toSet()) } + state.update { + it.copy( + articles = articles, + articlesLoading = false, + selectedArticles = selectedItems.toSet() + ) + } } } @@ -146,27 +153,40 @@ class PersonalizationViewModel( // as we have a single state it becomes easier to update and control the state fun onTopicSelected(topic: OnboardingTopic) { + val lang = WikipediaApp.instance.languageState.appLanguageCode + val isSelected = state.value.selectedTopics.contains(topic.topicId) + // When a category is selected, we want to reset the articles state and load articles for the selected category - val selectedTopics = if (state.value.selectedTopics.contains(topic.topicId)) { - state.value.selectedTopics - topic.topicId - } else { - state.value.selectedTopics + topic.topicId - } + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(topicsError = throwable) } + }) { + if (isSelected) { + repository.deleteTopic(topic, lang) + } else { + repository.saveTopic(topic, lang) + } - state.update { - it.copy( - selectedTopics = selectedTopics, - articles = emptyList(), - articlesLoading = true, - articlesError = null - ) - } + val selectedTopics = if (isSelected) { + state.value.selectedTopics - topic.topicId + } else { + state.value.selectedTopics + topic.topicId + } + + state.update { + it.copy( + selectedTopics = selectedTopics, + articles = emptyList(), + articlesLoading = true, + articlesError = null + ) + } - val topicQueryIds = selectedTopics.mapNotNull { topicApiLookUp[it] } - if (topicQueryIds.isEmpty()) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryIds.last()) + val topicQueryIds = selectedTopics.mapNotNull { topicApiLookUp[it] } + if (topicQueryIds.isEmpty()) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryIds.last()) + } } - fun addArticle(title: PageTitle) { + fun addArticleFromSearch(title: PageTitle) { state.update { val newItems = listOf(title) + it.articles val newSelection = it.selectedArticles + title @@ -175,13 +195,26 @@ class PersonalizationViewModel( } fun toggleSelection(title: PageTitle) { - state.update { - val newSelection = if (it.selectedArticles.contains(title)) { - it.selectedArticles - title + val lang = WikipediaApp.instance.languageState.appLanguageCode + val isSelected = state.value.selectedArticles.contains(title) + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + }) { + if (isSelected) { + repository.deleteArticle(title, lang) } else { - it.selectedArticles + title + repository.saveArticle(title, lang) + } + + state.update { + it.copy( + selectedArticles = if (isSelected) { + state.value.selectedArticles - title + } else { + state.value.selectedArticles + title + } + ) } - it.copy(selectedArticles = newSelection) } } @@ -194,4 +227,12 @@ class PersonalizationViewModel( ) } } + + fun retryLoading() { + if (state.value.selectedTopics.isNotEmpty()) { + loadArticlesByTopic(topic = topicApiLookUp[state.value.selectedTopics.last()].orEmpty()) + } else { + loadInitialArticles() + } + } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt new file mode 100644 index 00000000000..609bd99b9f0 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt @@ -0,0 +1,27 @@ +package org.wikipedia.onboarding.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.wikipedia.onboarding.personalization.db.entity.Interest +import org.wikipedia.onboarding.personalization.db.entity.InterestType + +@Dao +interface InterestDao { + @Query("SELECT * FROM Interests WHERE type = :type AND lang = :lang") + fun getByType(type: InterestType, lang: String): Flow> + + @Insert + suspend fun insert(interest: Interest) + + @Query("SELECT * FROM Interests WHERE topicKey = :topicId AND lang = :lang LIMIT 1") + suspend fun findTopic(topicId: String, lang: String): Interest? + + @Query("SELECT * FROM Interests WHERE articleApiTitle = :articleApiTitle AND lang = :lang LIMIT 1") + suspend fun findArticle(articleApiTitle: String, lang: String): Interest? + + @Delete + suspend fun delete(interest: Interest) +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt new file mode 100644 index 00000000000..80655557d0d --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt @@ -0,0 +1,26 @@ +package org.wikipedia.onboarding.personalization.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "Interests") +data class Interest( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val type: Int, // 0 = topic, 1 = article, use InterestType enum for better readability + val lang: String, + val topicLabel: String? = null, + val topicKey: String? = null, + var articleApiTitle: String? = null, + var articleDisplayTitle: String? = null, + var articleDescription: String? = null, + var articleThumbUrl: String? = null, +) + +enum class InterestType(val value: Int) { + TOPIC(0), + ARTICLE(1); + + companion object { + fun fromValue(value: Int) = entries.first { it.value == value } + } +} From 910a4e1165a383bd7c4b23ada5cb7a0cbdbd121c Mon Sep 17 00:00:00 2001 From: williamrai Date: Tue, 7 Apr 2026 15:50:15 -0400 Subject: [PATCH 14/39] - converts selectedTopics to list of onboarding for readability and access --- .../InterestOnboardingScreen.kt | 10 +++++++++- .../PersonalizationViewModel.kt | 20 +++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 8f308e39c66..3b24e1e2bf3 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -94,7 +94,15 @@ fun InterestOnboardingScreen( } item(span = StaggeredGridItemSpan.FullLine) { when (topicsState) { - is TopicsState.Error -> {} + is TopicsState.Error -> { + Text( + modifier = Modifier + .fillMaxWidth(), + text = topicsState.message, + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor + ) + } TopicsState.Loading -> { LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 287f102c473..9fedca8bbc6 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -28,7 +28,7 @@ private data class PersonalizedViewModelState( val articlesLoading: Boolean = false, val articlesError: Throwable? = null, val selectedArticles: Set = emptySet(), - val selectedTopics: Set = emptySet(), + val selectedTopics: List = emptyList(), val searchQuery: String = "", // Feed preference screen properties ) { @@ -42,7 +42,7 @@ private data class PersonalizedViewModelState( else -> TopicsState.Success( topics = topics.map { - it.copy(isSelected = selectedTopics.contains(it.topicId)) + it.copy(isSelected = selectedTopics.any { selected -> selected.topicId == it.topicId }) } ) }, @@ -69,7 +69,6 @@ class PersonalizationViewModel( ) : 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()) - private val topicApiLookUp = OnboardingTopics.all.associate { it.topicId to it.articleTopics } // Each screen observes only its own derived UI state // runs automatically when any part of the raw state changes @@ -154,7 +153,7 @@ class PersonalizationViewModel( // as we have a single state it becomes easier to update and control the state fun onTopicSelected(topic: OnboardingTopic) { val lang = WikipediaApp.instance.languageState.appLanguageCode - val isSelected = state.value.selectedTopics.contains(topic.topicId) + val isSelected = state.value.selectedTopics.any { selected -> selected.topicId == topic.topicId } // When a category is selected, we want to reset the articles state and load articles for the selected category viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> @@ -167,9 +166,9 @@ class PersonalizationViewModel( } val selectedTopics = if (isSelected) { - state.value.selectedTopics - topic.topicId + state.value.selectedTopics.filter { it.topicId != topic.topicId } } else { - state.value.selectedTopics + topic.topicId + state.value.selectedTopics + topic } state.update { @@ -181,8 +180,8 @@ class PersonalizationViewModel( ) } - val topicQueryIds = selectedTopics.mapNotNull { topicApiLookUp[it] } - if (topicQueryIds.isEmpty()) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryIds.last()) + val topicQueryId = selectedTopics.lastOrNull()?.articleTopics + if (topicQueryId == null) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryId) } } @@ -229,8 +228,9 @@ class PersonalizationViewModel( } fun retryLoading() { - if (state.value.selectedTopics.isNotEmpty()) { - loadArticlesByTopic(topic = topicApiLookUp[state.value.selectedTopics.last()].orEmpty()) + val last = state.value.selectedTopics.lastOrNull() + if (last != null) { + loadArticlesByTopic(topic = last.articleTopics) } else { loadInitialArticles() } From 24acbc6c97921c8f1c8fd4797c5c0e2d8ac4b438 Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 8 Apr 2026 08:59:32 -0400 Subject: [PATCH 15/39] - adds preview --- .../InterestOnboardingScreen.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 3b24e1e2bf3..33e7740ef78 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -41,15 +41,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import org.wikipedia.R 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.dataclient.WikiSite import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.recommended.ReadingListInterestCard import org.wikipedia.readinglist.recommended.ReadingListInterestSearchCard +import org.wikipedia.theme.Theme @Composable fun InterestOnboardingScreen( @@ -313,3 +318,41 @@ fun SelectionBottomBar( } } } + +@Preview(showBackground = true) +@Composable +private fun InterestOnboardingScreenPreview() { + val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") + val titles = listOf( + PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), + PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), + PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), + PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), + PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), + PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), + PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), + ) + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InterestOnboardingScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + topicsState = TopicsState.Success( + topics = OnboardingTopics.all.map { + it.copy(displayTitle = it.msgKey, isSelected = it.topicId == "science") + } + ), + articlesState = ArticlesState.Success( + articles = titles, + selectedArticles = setOf() + ), + onTopicSelected = {}, + onSearchClick = {}, + onDeselectAllClick = {}, + retryLoading = {} + ) + } +} From 8aa6030a5c037c925daca4ebbb47c2bd0b7dc273 Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 8 Apr 2026 13:50:37 -0400 Subject: [PATCH 16/39] - adds namespace to Interest entity - adds deleteAllByType query - adds viewModelFactory to PersonalizationViewModel.kt - code fixes and cleanups --- .../org/wikipedia/database/AppDatabase.kt | 1 + .../InterestOnboardingScreen.kt | 4 +- .../OnboardingCuriosityScreen.kt | 6 - .../personalization/OnboardingTopics.kt | 3 +- .../PersonalizationActivity.kt | 15 ++- .../PersonalizationRepository.kt | 22 +++- .../personalization/PersonalizationScreen.kt | 20 +-- .../PersonalizationViewModel.kt | 116 ++++++++++-------- .../personalization/db/dao/InterestDao.kt | 6 +- .../personalization/db/entity/Interest.kt | 2 + 10 files changed, 115 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index b24f6c174f1..c10f6d2c758 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -365,6 +365,7 @@ abstract class AppDatabase : RoomDatabase() { "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + "type INTEGER NOT NULL," + "lang TEXT NOT NULL," + + "namespace INTEGER," + "topicLabel TEXT," + "topicKey TEXT," + "articleApiTitle TEXT," + diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 33e7740ef78..a8b406717b7 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -71,7 +71,6 @@ fun InterestOnboardingScreen( val transition = rememberInfiniteTransition(label = "shimmerTransition") Box(modifier = modifier) { Column( - modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( @@ -217,7 +216,8 @@ fun TopicFilterChipRow( onClick = { onTopicSelected(item) }, leadingIcon = { AnimatedContent( - targetState = item.isSelected + targetState = item.isSelected, + label = "topicSelectionIcon" ) { isSelected -> Icon( modifier = Modifier diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt index 1fcca5133f3..7924b8043c9 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingCuriosityScreen.kt @@ -79,12 +79,6 @@ fun OnboardingCuriosityScreen( } } -data class InterestOnboardingUiState( - val isLoading: Boolean = true, - val items: List = emptyList(), - val selectedItems: Set = emptySet() -) - @Preview @Composable private fun OnboardingCuriosityScreenPreview() { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt index 33c431c3f05..2b8dfe75ef4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt @@ -1,8 +1,7 @@ package org.wikipedia.onboarding.personalization +// the values defined here are from https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/WikimediaMessages/+/refs/heads/master/includes/ArticleTopicFiltersRegistry.php object OnboardingTopics { - // Linguistics and Media is not in the filter registry - // americas cannot be queried val all = listOf( OnboardingTopic( topicId = "biography", diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index 61ec504dc5d..e56d7ddec39 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -15,11 +15,11 @@ import org.wikipedia.search.SearchActivity class PersonalizationActivity : BaseActivity() { - private val viewModel: PersonalizationViewModel by viewModels() + private val viewModel: PersonalizationViewModel by viewModels { PersonalizationViewModel.Factory } private val searchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == SearchActivity.RESULT_LINK_SUCCESS) { - val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE)!! + val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE) ?: return@registerForActivityResult viewModel.addArticleFromSearch(pageTitle) } } @@ -31,6 +31,11 @@ class PersonalizationActivity : BaseActivity() { BaseTheme { PersonalizationScreen( viewModel = viewModel, + screens = listOf( + PersonalizationPage.CURIOSITY, + PersonalizationPage.INTERESTS, + PersonalizationPage.FEED_PREFERENCE + ), onSkipClick = { finish() }, onSearchClick = { val intent = SearchActivity.newIntent(this, Constants.InvokeSource.INTEREST_SELECTION, null, returnLink = true) @@ -47,3 +52,9 @@ class PersonalizationActivity : BaseActivity() { } } } + +enum class PersonalizationPage { + CURIOSITY, + INTERESTS, + FEED_PREFERENCE +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index e49baa48037..ba5f023a5a2 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -1,17 +1,22 @@ package org.wikipedia.onboarding.personalization import org.wikipedia.WikipediaApp -import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.history.db.HistoryEntryWithImageDao import org.wikipedia.onboarding.personalization.db.dao.InterestDao import org.wikipedia.onboarding.personalization.db.entity.Interest import org.wikipedia.onboarding.personalization.db.entity.InterestType import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage +import org.wikipedia.readinglist.db.ReadingListPageDao import org.wikipedia.util.StringUtil -class PersonalizationRepository(private val interestDao: InterestDao) { +class PersonalizationRepository( + private val interestDao: InterestDao, + private val historyEntryWithImageDao: HistoryEntryWithImageDao, + private val readingListPageDao: ReadingListPageDao +) { suspend fun getTopics(langCode: String): List { val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } @@ -21,11 +26,11 @@ class PersonalizationRepository(private val interestDao: InterestDao) { ?.associate { it.name to it.content } .orEmpty() return OnboardingTopics.all.map { topic -> - topic.copy(displayTitle = translations[topic.msgKey] ?: "no") + topic.copy(displayTitle = translations[topic.msgKey] ?: topic.displayTitle) } } - suspend fun getArticlesBytTopic(topic: String): List { + suspend fun getArticlesByTopic(topic: String): List { val searchTerm = "articletopic:$topic" val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getArticlesByTopic(searchTerm, 25) val pageList = response.query?.pages @@ -48,10 +53,10 @@ class PersonalizationRepository(private val interestDao: InterestDao) { if (results.size < maxItems) { // get most recent history entries - val historyTitles = AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(maxItems, 0) + val historyTitles = historyEntryWithImageDao.findEntryForReadMore(maxItems, 0) .map { it.title } // and a random sampling of reading list pages - val readingListTitles = AppDatabase.instance.readingListPageDao().getPagesByRandom(maxItems) + val readingListTitles = readingListPageDao.getPagesByRandom(maxItems) .map { ReadingListPage.toPageTitle(it) } // take the two lists and interleave them for (i in 0 until maxItems) { @@ -114,6 +119,7 @@ class PersonalizationRepository(private val interestDao: InterestDao) { Interest( type = InterestType.ARTICLE.value, lang = lang, + namespace = article.namespace(), articleApiTitle = article.prefixedText, articleDisplayTitle = article.displayText, articleDescription = article.description, @@ -127,4 +133,8 @@ class PersonalizationRepository(private val interestDao: InterestDao) { interestDao.delete(it) } } + + suspend fun deleteAllArticles(lang: String) { + interestDao.deleteAllByType(InterestType.ARTICLE.value, lang) + } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index f350627cdb6..972cb7b43a8 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -37,16 +37,17 @@ import org.wikipedia.compose.theme.WikipediaTheme @Composable fun PersonalizationScreen( modifier: Modifier = Modifier, + screens: List, onSkipClick: () -> Unit, onSearchClick: () -> Unit, viewModel: PersonalizationViewModel ) { val coroutineScope = rememberCoroutineScope() val uiState = viewModel.interestUiState.collectAsState() - val pagerState = rememberPagerState(pageCount = { 3 }) + val pagerState = rememberPagerState(pageCount = { screens.size }) LaunchedEffect(pagerState.currentPage) { - viewModel.onPageChanged(pagerState.currentPage) + viewModel.onPageChanged(screens[pagerState.currentPage]) } Scaffold( @@ -56,7 +57,6 @@ fun PersonalizationScreen( onNavigationRightClick = { coroutineScope.launch { if (pagerState.currentPage < pagerState.pageCount - 1) { - viewModel.onPageChanged(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1) } else { onSkipClick() @@ -74,10 +74,12 @@ fun PersonalizationScreen( ) { HorizontalPager( state = pagerState - ) { page -> - when (page) { - 0 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) - 1 -> { + ) { pageIndex -> + when (screens[pageIndex]) { + PersonalizationPage.CURIOSITY -> { + OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) + } + PersonalizationPage.INTERESTS -> { InterestOnboardingScreen( modifier = Modifier .fillMaxSize() @@ -100,7 +102,9 @@ fun PersonalizationScreen( } ) } - 2 -> OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) + PersonalizationPage.FEED_PREFERENCE -> { + // TODO: implement feed preference screen + } } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 9fedca8bbc6..ea527fbe000 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -2,6 +2,8 @@ package org.wikipedia.onboarding.personalization import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -65,7 +67,7 @@ private data class PersonalizedViewModelState( } class PersonalizationViewModel( - private val repository: PersonalizationRepository = PersonalizationRepository(AppDatabase.instance.interestDao()) + private val repository: PersonalizationRepository ) : 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()) @@ -80,13 +82,14 @@ class PersonalizationViewModel( initialValue = state.value.toInterestUiState() ) - fun onPageChanged(page: Int) { - when (page) { - 1 -> { + fun onPageChanged(screen: PersonalizationPage) { + when (screen) { + PersonalizationPage.INTERESTS -> { val langCode = WikipediaApp.instance.languageState.appLanguageCode - loadTopics(langCode) - loadInitialArticles() + if (state.value.topics.isEmpty()) loadTopics(langCode) + if (state.value.articles.isEmpty()) loadInitialArticles() } + else -> {} } } @@ -94,16 +97,9 @@ class PersonalizationViewModel( viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(topicsLoading = false, topicsError = throwable) } }) { - state.update { it.copy(topicsLoading = true) } - val current = state.value - - if (current.topics.isNotEmpty()) { - state.update { it.copy(topics = current.topics, topicsLoading = false) } - return@launch - } + state.update { it.copy(topicsLoading = true, topicsError = null) } val topics = repository.getTopics(langCode) - state.update { it.copy(topics = topics, topicsLoading = false) } } } @@ -112,13 +108,7 @@ class PersonalizationViewModel( viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { - state.update { it.copy(articlesLoading = true) } - val current = state.value - - if (current.articles.isNotEmpty()) { - state.update { it.copy(articles = current.articles, articlesLoading = false) } - return@launch - } + state.update { it.copy(articlesLoading = true, articlesError = null) } val selectedItems = Prefs.recommendedReadingListInterests val articles = repository.loadInitialArticles(selectedItems) @@ -136,17 +126,13 @@ class PersonalizationViewModel( viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { - state.update { it.copy(articlesLoading = true) } - val current = state.value + state.update { it.copy(articlesLoading = true, articlesError = null) } - if (current.articles.isNotEmpty()) { - state.update { it.copy(articles = current.articles, articlesLoading = false) } - return@launch + val articles = repository.getArticlesByTopic(topic) + state.update { current -> + val newArticles = (current.selectedArticles.toList() + articles).distinct() + current.copy(articles = newArticles, articlesLoading = false) } - - val articles = repository.getArticlesBytTopic(topic) - val newArticles = (current.selectedArticles.toList() + articles).distinct() - state.update { it.copy(articles = newArticles, articlesLoading = false) } } } @@ -159,20 +145,20 @@ class PersonalizationViewModel( viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(topicsError = throwable) } }) { - if (isSelected) { - repository.deleteTopic(topic, lang) - } else { - repository.saveTopic(topic, lang) - } - val selectedTopics = if (isSelected) { state.value.selectedTopics.filter { it.topicId != topic.topicId } } else { state.value.selectedTopics + topic } - state.update { - it.copy( + if (isSelected) { + repository.deleteTopic(topic, lang) + } else { + repository.saveTopic(topic, lang) + } + + state.update { current -> + current.copy( selectedTopics = selectedTopics, articles = emptyList(), articlesLoading = true, @@ -186,10 +172,16 @@ class PersonalizationViewModel( } fun addArticleFromSearch(title: PageTitle) { - state.update { - val newItems = listOf(title) + it.articles - val newSelection = it.selectedArticles + title - it.copy(articles = newItems, selectedArticles = newSelection) + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + } + ) { + repository.saveArticle(title, WikipediaApp.instance.languageState.appLanguageCode) + state.update { + val newItems = listOf(title) + it.articles + val newSelection = it.selectedArticles + title + it.copy(articles = newItems, selectedArticles = newSelection) + } } } @@ -205,12 +197,12 @@ class PersonalizationViewModel( repository.saveArticle(title, lang) } - state.update { - it.copy( + state.update { current -> + current.copy( selectedArticles = if (isSelected) { - state.value.selectedArticles - title + current.selectedArticles - title } else { - state.value.selectedArticles + title + current.selectedArticles + title } ) } @@ -218,12 +210,18 @@ class PersonalizationViewModel( } fun deselectAllArticles() { - state.update { - it.copy( - selectedArticles = emptySet(), - articlesLoading = false, - articlesError = null - ) + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + } + ) { + repository.deleteAllArticles(lang = WikipediaApp.instance.languageState.appLanguageCode) + state.update { + it.copy( + selectedArticles = emptySet(), + articlesLoading = false, + articlesError = null + ) + } } } @@ -235,4 +233,18 @@ class PersonalizationViewModel( loadInitialArticles() } } + + companion object { + val Factory = viewModelFactory { + initializer { + PersonalizationViewModel( + repository = PersonalizationRepository( + interestDao = AppDatabase.instance.interestDao(), + historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), + readingListPageDao = AppDatabase.instance.readingListPageDao() + ) + ) + } + } + } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt index 609bd99b9f0..9e367080958 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt @@ -6,12 +6,11 @@ import androidx.room.Insert import androidx.room.Query import kotlinx.coroutines.flow.Flow import org.wikipedia.onboarding.personalization.db.entity.Interest -import org.wikipedia.onboarding.personalization.db.entity.InterestType @Dao interface InterestDao { @Query("SELECT * FROM Interests WHERE type = :type AND lang = :lang") - fun getByType(type: InterestType, lang: String): Flow> + fun getByType(type: Int, lang: String): Flow> @Insert suspend fun insert(interest: Interest) @@ -24,4 +23,7 @@ interface InterestDao { @Delete suspend fun delete(interest: Interest) + + @Query("DELETE FROM Interests WHERE type = :type AND lang = :lang") + suspend fun deleteAllByType(type: Int, lang: String) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt index 80655557d0d..9d764d54b0a 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt @@ -2,12 +2,14 @@ package org.wikipedia.onboarding.personalization.db.entity import androidx.room.Entity import androidx.room.PrimaryKey +import org.wikipedia.page.Namespace @Entity(tableName = "Interests") data class Interest( @PrimaryKey(autoGenerate = true) val id: Int = 0, val type: Int, // 0 = topic, 1 = article, use InterestType enum for better readability val lang: String, + val namespace: Namespace? = null, val topicLabel: String? = null, val topicKey: String? = null, var articleApiTitle: String? = null, From 7d3cfea0fa3dc5643dd91507c5d49a50eef7c350 Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 8 Apr 2026 16:29:14 -0400 Subject: [PATCH 17/39] - rename and moves re-usable interest selection components to compose/components --- .../compose/components/ArticleCard.kt | 108 +++++++++++++++ .../compose/components/SearchBarCard.kt | 57 ++++++++ .../InterestOnboardingScreen.kt | 8 +- ...RecommendedReadingListInterestsFragment.kt | 130 +----------------- 4 files changed, 173 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/compose/components/ArticleCard.kt create mode 100644 app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt diff --git a/app/src/main/java/org/wikipedia/compose/components/ArticleCard.kt b/app/src/main/java/org/wikipedia/compose/components/ArticleCard.kt new file mode 100644 index 00000000000..a31b51cf005 --- /dev/null +++ b/app/src/main/java/org/wikipedia/compose/components/ArticleCard.kt @@ -0,0 +1,108 @@ +package org.wikipedia.compose.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.page.PageTitle +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun ArticleCard( + modifier: Modifier, + item: PageTitle, + isSelected: Boolean = false, + onItemClick: (PageTitle) -> Unit = {}, +) { + WikiCard( + modifier = modifier + .fillMaxWidth(), + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) WikipediaTheme.colors.additionColor else WikipediaTheme.colors.paperColor + ), + shape = RoundedCornerShape(16.dp), + onClick = { + onItemClick(item) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + if (!item.thumbUrl.isNullOrEmpty()) { + val request = ImageService.getRequest(LocalContext.current, url = item.thumbUrl, 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) + .clip(RoundedCornerShape(16.dp)) + ) + } + Column( + modifier = Modifier.padding(8.dp) + ) { + HtmlText( + text = item.displayText, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + Spacer(modifier = Modifier.height(2.dp)) + Row { + if (!item.description.isNullOrEmpty()) { + HtmlText( + text = item.description.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + modifier = Modifier.size(24.dp).align(Alignment.Bottom), + painter = painterResource(R.drawable.check_circle_24px), + tint = WikipediaTheme.colors.primaryColor, + contentDescription = null + ) + } else { + Spacer(modifier = Modifier.width(32.dp).height(24.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt new file mode 100644 index 00000000000..20a4a12260a --- /dev/null +++ b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt @@ -0,0 +1,57 @@ +package org.wikipedia.compose.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.compose.theme.WikipediaTheme + +@Composable +fun SearchBarCard( + modifier: Modifier = Modifier, + onSearchClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(28.dp)) + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = RoundedCornerShape(24.dp) + ) + .clickable(onClick = onSearchClick), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(16.dp)) + Icon( + painter = painterResource(R.drawable.outline_search_24), + contentDescription = stringResource(R.string.search_hint), + tint = WikipediaTheme.colors.secondaryColor, + modifier = Modifier.size(24.dp) + ) + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index a8b406717b7..9865326f1d8 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import org.wikipedia.R +import org.wikipedia.compose.components.ArticleCard +import org.wikipedia.compose.components.SearchBarCard import org.wikipedia.compose.components.error.WikiErrorClickEvents import org.wikipedia.compose.components.error.WikiErrorView import org.wikipedia.compose.extensions.shimmerEffect @@ -52,8 +54,6 @@ import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.PageTitle -import org.wikipedia.readinglist.recommended.ReadingListInterestCard -import org.wikipedia.readinglist.recommended.ReadingListInterestSearchCard import org.wikipedia.theme.Theme @Composable @@ -92,7 +92,7 @@ fun InterestOnboardingScreen( contentPadding = PaddingValues(16.dp), content = { item(span = StaggeredGridItemSpan.FullLine) { - ReadingListInterestSearchCard( + SearchBarCard( onSearchClick = onSearchClick ) } @@ -169,7 +169,7 @@ fun InterestOnboardingScreen( is ArticlesState.Success -> { items(articlesState.articles) { item -> - ReadingListInterestCard( + ArticleCard( modifier = Modifier.animateItem(), item = item, isSelected = articlesState.selectedArticles.contains(item), diff --git a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt index 017f952b757..8159cc38c81 100644 --- a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt @@ -11,12 +11,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -31,8 +29,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -51,21 +47,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.painter.BrushPainter -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -73,13 +63,12 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import coil3.compose.AsyncImage import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.analytics.eventplatform.RecommendedReadingListEvent -import org.wikipedia.compose.components.HtmlText -import org.wikipedia.compose.components.WikiCard +import org.wikipedia.compose.components.ArticleCard +import org.wikipedia.compose.components.SearchBarCard import org.wikipedia.compose.components.WikipediaAlertDialog import org.wikipedia.compose.components.error.WikiErrorClickEvents import org.wikipedia.compose.components.error.WikiErrorView @@ -95,7 +84,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.theme.Theme import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.Resource -import org.wikipedia.views.imageservice.ImageService class RecommendedReadingListInterestsFragment : Fragment() { private val viewModel: RecommendedReadingListInterestsViewModel by viewModels() @@ -362,10 +350,10 @@ fun RecommendedReadingListInterestsContent( ) } item(span = StaggeredGridItemSpan.FullLine) { - ReadingListInterestSearchCard(onSearchClick = onSearchClick) + SearchBarCard(onSearchClick = onSearchClick) } items(items) { item -> - ReadingListInterestCard( + ArticleCard( modifier = Modifier.animateItem(), item = item, isSelected = selectedItems.contains(item), @@ -441,116 +429,6 @@ fun RecommendedReadingListInterestsContent( } } -@Composable -fun ReadingListInterestCard( - modifier: Modifier, - item: PageTitle, - isSelected: Boolean = false, - onItemClick: (PageTitle) -> Unit = {}, -) { - WikiCard( - modifier = modifier - .fillMaxWidth(), - elevation = 0.dp, - border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) WikipediaTheme.colors.additionColor else WikipediaTheme.colors.paperColor - ), - shape = RoundedCornerShape(16.dp), - onClick = { - onItemClick(item) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - ) { - if (!item.thumbUrl.isNullOrEmpty()) { - val request = ImageService.getRequest(LocalContext.current, url = item.thumbUrl, 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) - .clip(RoundedCornerShape(16.dp)) - ) - } - Column( - modifier = Modifier.padding(8.dp) - ) { - HtmlText( - text = item.displayText, - style = MaterialTheme.typography.bodyLarge, - color = WikipediaTheme.colors.primaryColor - ) - Spacer(modifier = Modifier.height(2.dp)) - Row { - if (!item.description.isNullOrEmpty()) { - HtmlText( - text = item.description.orEmpty(), - style = MaterialTheme.typography.bodyMedium, - color = WikipediaTheme.colors.secondaryColor, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - if (isSelected) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - modifier = Modifier.size(24.dp).align(Alignment.Bottom), - painter = painterResource(R.drawable.check_circle_24px), - tint = WikipediaTheme.colors.primaryColor, - contentDescription = null - ) - } else { - Spacer(modifier = Modifier.width(32.dp).height(24.dp)) - } - } - } - } - } -} - -@Composable -fun ReadingListInterestSearchCard( - modifier: Modifier = Modifier, - onSearchClick: () -> Unit -) { - Row( - modifier = modifier - .fillMaxWidth() - .height(56.dp) - .clip(RoundedCornerShape(28.dp)) - .background( - color = WikipediaTheme.colors.backgroundColor, - shape = RoundedCornerShape(24.dp) - ) - .clickable(onClick = onSearchClick), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width(16.dp)) - Icon( - painter = painterResource(R.drawable.outline_search_24), - contentDescription = stringResource(R.string.search_hint), - tint = WikipediaTheme.colors.secondaryColor, - modifier = Modifier.size(24.dp) - ) - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp), - text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), - style = MaterialTheme.typography.bodyLarge, - color = WikipediaTheme.colors.primaryColor - ) - } -} - @Preview(showBackground = true) @Composable fun PreviewReadingListInterestsScreen() { From eed54e780033c8163f2b0348270c07ae3a985538 Mon Sep 17 00:00:00 2001 From: Cooltey Feng Date: Wed, 8 Apr 2026 16:34:26 -0700 Subject: [PATCH 18/39] fix lint --- app/src/main/res/values-qq/strings.xml | 9 +++------ app/src/main/res/values/strings.xml | 3 --- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 5e3e57ad3c4..32864445ff7 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2227,12 +2227,9 @@ Label text for the explore feed type tab for the community content. Label text for the explore feed type tab for the personalized content. Label text for the explore feed to load more content. -<<<<<<< ef-interest - Display name for the title of the Explore feed onboarding screen prompting users to follow topics. - Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. - Button text to deselect all selected items in the intereset selection screen. -======= + Display name for the title of the Explore feed onboarding screen prompting users to follow topics. + Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. + 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. ->>>>>>> explore-feed-upgrade-design diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c78a243e0b5..26bf0c42006 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2480,12 +2480,9 @@ From the community For you Load previous day -<<<<<<< ef-interest Follow your curiosity 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 and they are updated daily ->>>>>>> explore-feed-upgrade-design \ No newline at end of file From 4295b9968ff250cf479a31be731a9d519c4a0977 Mon Sep 17 00:00:00 2001 From: williamrai Date: Thu, 9 Apr 2026 10:33:58 -0400 Subject: [PATCH 19/39] - code fixes --- .../java/org/wikipedia/feed/FeedFragment.kt | 9 --------- .../java/org/wikipedia/feed/HomeFragment.kt | 15 +++++++++++++++ .../ExploreFeedUpdatePromptActivity.kt | 5 ++--- .../onboarding/InitialOnboardingActivity.kt | 2 +- .../InterestOnboardingScreen.kt | 10 ++++------ .../PersonalizationRepository.kt | 18 ++++++++++-------- .../PersonalizationViewModel.kt | 17 ++++++++--------- .../{ => topics}/OnboardingTopics.kt | 4 +++- app/src/main/res/values-qq/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 10 files changed, 45 insertions(+), 39 deletions(-) rename app/src/main/java/org/wikipedia/onboarding/personalization/{ => topics}/OnboardingTopics.kt (98%) diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt index e27f17d8e06..e92e8d6bd12 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt @@ -29,7 +29,6 @@ import org.wikipedia.feed.model.Card import org.wikipedia.feed.model.WikiSiteCard import org.wikipedia.feed.news.NewsCard import org.wikipedia.feed.news.NewsItemView -import org.wikipedia.feed.onboarding.ExploreFeedUpdatePromptActivity import org.wikipedia.feed.random.RandomCardView import org.wikipedia.feed.topread.TopReadArticlesActivity import org.wikipedia.feed.topread.TopReadListCard @@ -101,7 +100,6 @@ class FeedFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) coordinator.more(app.wikiSite) - maybeShowExploreFeedUpdatePrompt() } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -421,13 +419,6 @@ class FeedFragment : Fragment() { } } - private fun maybeShowExploreFeedUpdatePrompt() { - // TODO: Set this to true during the new user onboarding flow to prevent this prompt from appearing - if (!Prefs.isInitialOnboardingEnabled && Prefs.isExploreFeedUpdatePromptShown.not()) { - startActivity(ExploreFeedUpdatePromptActivity.newIntent(requireContext())) - } - } - private fun showConfigureActivity(invokeSource: Int) { requestFeedConfigurationLauncher.launch(ConfigureActivity.newIntent(requireActivity(), invokeSource)) } diff --git a/app/src/main/java/org/wikipedia/feed/HomeFragment.kt b/app/src/main/java/org/wikipedia/feed/HomeFragment.kt index 9231c62f587..e1a5c631ee1 100644 --- a/app/src/main/java/org/wikipedia/feed/HomeFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/HomeFragment.kt @@ -67,6 +67,7 @@ import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.feed.featured.FeaturedArticleModule import org.wikipedia.feed.image.FeaturedImage import org.wikipedia.feed.image.FeaturedImageModule +import org.wikipedia.feed.onboarding.ExploreFeedUpdatePromptActivity import org.wikipedia.feed.topread.TopReadArticlesActivity import org.wikipedia.feed.topread.TopReadListCard import org.wikipedia.feed.topread.TopReadModule @@ -74,6 +75,7 @@ import org.wikipedia.history.HistoryEntry import org.wikipedia.main.MainActivity import org.wikipedia.main.MainFragment import org.wikipedia.navtab.NavTab +import org.wikipedia.settings.Prefs import org.wikipedia.theme.Theme import org.wikipedia.util.DimenUtil import org.wikipedia.util.ShareUtil @@ -83,6 +85,13 @@ import java.time.LocalDate class HomeFragment : Fragment() { private val viewModel: HomeViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + maybeShowExploreFeedUpdatePrompt() + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -131,6 +140,12 @@ class HomeFragment : Fragment() { fun getCurrentTab(): HomeTab { return viewModel.selectedTab.value } + + private fun maybeShowExploreFeedUpdatePrompt() { + if (!Prefs.isInitialOnboardingEnabled && Prefs.isExploreFeedUpdatePromptShown.not()) { + startActivity(ExploreFeedUpdatePromptActivity.newIntent(requireContext())) + } + } } @Composable diff --git a/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt index fe61f22c129..039664b8db3 100644 --- a/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt +++ b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt @@ -27,6 +27,7 @@ import org.wikipedia.compose.components.OnboardingListItem import org.wikipedia.compose.components.TwoButtonBottomBar import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.onboarding.personalization.PersonalizationActivity import org.wikipedia.settings.Prefs import org.wikipedia.theme.Theme @@ -62,12 +63,10 @@ class ExploreFeedUpdatePromptActivity : BaseActivity() { ExploreFeedUpdatePromptScreen( onSetItUpForMeClick = { finish() - // TODO: navigate directly to the feed. }, onCustomizeFeedClick = { - startActivity(ExploreFeedBuildingActivity.newIntent(this)) + startActivity(PersonalizationActivity.newIntent(this)) finish() - // TODO: navigate to condensed onboarding flow (Interests Selection, Feed Preference and Feed Loading screens) } ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index 81cef840b2d..5e4025c14f7 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/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 9865326f1d8..e8cc18af792 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,6 +52,7 @@ import org.wikipedia.compose.extensions.shimmerEffect import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.dataclient.WikiSite +import org.wikipedia.onboarding.personalization.topics.OnboardingTopics import org.wikipedia.page.PageTitle import org.wikipedia.theme.Theme @@ -112,12 +112,10 @@ fun InterestOnboardingScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 16.dp) ) { - items(5) { index -> - val width = - remember { listOf(80, 100, 70, 90, 85)[index].dp } + items(5) { Box( modifier = Modifier - .width(width) + .width(80.dp) .height(32.dp) .clip(RoundedCornerShape(size = 8.dp)) .shimmerEffect(transition = transition) @@ -295,7 +293,7 @@ fun SelectionBottomBar( Text( modifier = Modifier .clip(RoundedCornerShape(size = 8.dp)), - text = stringResource(R.string.explore_feed_deselect_all_btn_label), + text = stringResource(R.string.explore_feed_deselect_all_button_label), style = MaterialTheme.typography.labelLarge ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index ba5f023a5a2..01449650ad9 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -1,11 +1,12 @@ package org.wikipedia.onboarding.personalization -import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite import org.wikipedia.history.db.HistoryEntryWithImageDao import org.wikipedia.onboarding.personalization.db.dao.InterestDao import org.wikipedia.onboarding.personalization.db.entity.Interest import org.wikipedia.onboarding.personalization.db.entity.InterestType +import org.wikipedia.onboarding.personalization.topics.OnboardingTopics import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage @@ -15,12 +16,13 @@ import org.wikipedia.util.StringUtil class PersonalizationRepository( private val interestDao: InterestDao, private val historyEntryWithImageDao: HistoryEntryWithImageDao, - private val readingListPageDao: ReadingListPageDao + private val readingListPageDao: ReadingListPageDao, + val wikiSite: WikiSite ) { suspend fun getTopics(langCode: String): List { val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } - val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getMessages(messages = allMsgKey, args = null, lang = langCode) + val response = ServiceFactory.get(wikiSite).getMessages(messages = allMsgKey, args = null, lang = langCode) val translations = response.query?.allmessages ?.filterNot { it.missing } ?.associate { it.name to it.content } @@ -32,15 +34,15 @@ class PersonalizationRepository( suspend fun getArticlesByTopic(topic: String): List { val searchTerm = "articletopic:$topic" - val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getArticlesByTopic(searchTerm, 25) + val response = ServiceFactory.get(wikiSite).getArticlesByTopic(searchTerm, 25) val pageList = response.query?.pages ?.map { page -> PageTitle( text = page.title, - wiki = WikipediaApp.instance.wikiSite, + wiki = wikiSite, thumbUrl = page.thumbUrl(), description = page.description, - displayText = page.displayTitle(WikipediaApp.instance.wikiSite.languageCode) + displayText = page.displayTitle(wikiSite.languageCode) ) } ?: emptyList() return pageList @@ -71,8 +73,8 @@ class PersonalizationRepository( val maxRandomItems = 6 if (results.size < maxRandomItems) { for (i in results.size until maxRandomItems) { - val title = ServiceFactory.getRest(WikipediaApp.instance.wikiSite).getRandomSummary() - .getPageTitle(WikipediaApp.instance.wikiSite) + val title = ServiceFactory.getRest(wikiSite).getRandomSummary() + .getPageTitle(wikiSite) if (!results.contains(title)) { results.add(title) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index ea527fbe000..2779ab21197 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -30,8 +30,7 @@ private data class PersonalizedViewModelState( val articlesLoading: Boolean = false, val articlesError: Throwable? = null, val selectedArticles: Set = emptySet(), - val selectedTopics: List = emptyList(), - val searchQuery: String = "", + val selectedTopics: List = emptyList() // Feed preference screen properties ) { fun toInterestUiState(): InterestUiState { @@ -85,8 +84,7 @@ class PersonalizationViewModel( fun onPageChanged(screen: PersonalizationPage) { when (screen) { PersonalizationPage.INTERESTS -> { - val langCode = WikipediaApp.instance.languageState.appLanguageCode - if (state.value.topics.isEmpty()) loadTopics(langCode) + if (state.value.topics.isEmpty()) loadTopics(repository.wikiSite.languageCode) if (state.value.articles.isEmpty()) loadInitialArticles() } else -> {} @@ -138,7 +136,7 @@ class PersonalizationViewModel( // as we have a single state it becomes easier to update and control the state fun onTopicSelected(topic: OnboardingTopic) { - val lang = WikipediaApp.instance.languageState.appLanguageCode + val lang = repository.wikiSite.languageCode val isSelected = state.value.selectedTopics.any { selected -> selected.topicId == topic.topicId } // When a category is selected, we want to reset the articles state and load articles for the selected category @@ -176,7 +174,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.saveArticle(title, WikipediaApp.instance.languageState.appLanguageCode) + repository.saveArticle(title, repository.wikiSite.languageCode) state.update { val newItems = listOf(title) + it.articles val newSelection = it.selectedArticles + title @@ -186,7 +184,7 @@ class PersonalizationViewModel( } fun toggleSelection(title: PageTitle) { - val lang = WikipediaApp.instance.languageState.appLanguageCode + val lang = repository.wikiSite.languageCode val isSelected = state.value.selectedArticles.contains(title) viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesError = throwable) } @@ -214,7 +212,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.deleteAllArticles(lang = WikipediaApp.instance.languageState.appLanguageCode) + repository.deleteAllArticles(lang = repository.wikiSite.languageCode) state.update { it.copy( selectedArticles = emptySet(), @@ -241,7 +239,8 @@ class PersonalizationViewModel( repository = PersonalizationRepository( interestDao = AppDatabase.instance.interestDao(), historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), - readingListPageDao = AppDatabase.instance.readingListPageDao() + readingListPageDao = AppDatabase.instance.readingListPageDao(), + wikiSite = WikipediaApp.instance.wikiSite ) ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt similarity index 98% rename from app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt rename to app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt index 2b8dfe75ef4..cfd0a82cd6b 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/OnboardingTopics.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt @@ -1,4 +1,6 @@ -package org.wikipedia.onboarding.personalization +package org.wikipedia.onboarding.personalization.topics + +import org.wikipedia.onboarding.personalization.OnboardingTopic // the values defined here are from https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/WikimediaMessages/+/refs/heads/master/includes/ArticleTopicFiltersRegistry.php object OnboardingTopics { diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 32864445ff7..338adff1d3b 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2229,7 +2229,7 @@ Label text for the explore feed to load more content. Display name for the title of the Explore feed onboarding screen prompting users to follow topics. Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. - Button text to deselect all selected items in the intereset selection screen. + 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. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26bf0c42006..e25c6ba64ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2482,7 +2482,7 @@ Load previous day Follow your curiosity Select topics that interest you and we will personalize your feed.\n\nWe collect minimal data that is anonymized. - Deselect all + 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 \ No newline at end of file From ede922372cf00da67063e50d201b9c1eb5d7f717 Mon Sep 17 00:00:00 2001 From: williamrai Date: Thu, 9 Apr 2026 11:04:46 -0400 Subject: [PATCH 20/39] - renames articleTopics to queryTopicId --- .../personalization/PersonalizationState.kt | 2 +- .../PersonalizationViewModel.kt | 4 +- .../topics/OnboardingTopics.kt | 78 +++++++++---------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 0e3530a6ebd..0f1156adcfb 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -6,7 +6,7 @@ import org.wikipedia.page.PageTitle data class OnboardingTopic( val topicId: String, val msgKey: String, - val articleTopics: String, + val queryTopicId: String, val displayTitle: String, val isSelected: Boolean = false ) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 2779ab21197..bb4e54ca26e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -164,7 +164,7 @@ class PersonalizationViewModel( ) } - val topicQueryId = selectedTopics.lastOrNull()?.articleTopics + val topicQueryId = selectedTopics.lastOrNull()?.queryTopicId if (topicQueryId == null) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryId) } } @@ -226,7 +226,7 @@ class PersonalizationViewModel( fun retryLoading() { val last = state.value.selectedTopics.lastOrNull() if (last != null) { - loadArticlesByTopic(topic = last.articleTopics) + loadArticlesByTopic(topic = last.queryTopicId) } else { loadInitialArticles() } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt index cfd0a82cd6b..b759f2a4399 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt @@ -8,235 +8,235 @@ object OnboardingTopics { OnboardingTopic( topicId = "biography", msgKey = "wikimedia-articletopics-topic-biography", - articleTopics = "biography", + queryTopicId = "biography", displayTitle = "Biography" ), OnboardingTopic( topicId = "food-and-drink", msgKey = "wikimedia-articletopics-topic-food-and-drink", - articleTopics = "food-and-drink", + queryTopicId = "food-and-drink", displayTitle = "Food and Drink" ), OnboardingTopic( topicId = "computers-and-internet", // registry uses "computers-and-internet" as topicId but articleTopics = "internet-culture" msgKey = "wikimedia-articletopics-topic-computers-and-internet", - articleTopics = "internet-culture", + queryTopicId = "internet-culture", displayTitle = "Internet Culture" ), OnboardingTopic( topicId = "history", msgKey = "wikimedia-articletopics-topic-history", - articleTopics = "history", + queryTopicId = "history", displayTitle = "History" ), OnboardingTopic( topicId = "literature", msgKey = "wikimedia-articletopics-topic-literature", - articleTopics = "books", // registry uses "literature" as topicId but articleTopics = "books" + queryTopicId = "books", // registry uses "literature" as topicId but articleTopics = "books" displayTitle = "Literature" ), OnboardingTopic( topicId = "performing-arts", msgKey = "wikimedia-articletopics-topic-performing-arts", - articleTopics = "performing-arts", + queryTopicId = "performing-arts", displayTitle = "Performing arts" ), OnboardingTopic( topicId = "philosophy-and-religion", msgKey = "wikimedia-articletopics-topic-philosophy-and-religion", - articleTopics = "philosophy-and-religion", + queryTopicId = "philosophy-and-religion", displayTitle = "Philosophy and religion" ), OnboardingTopic( topicId = "sports", msgKey = "wikimedia-articletopics-topic-sports", - articleTopics = "sports", + queryTopicId = "sports", displayTitle = "Sports" ), OnboardingTopic( topicId = "art", // registry uses "art" as topicId but articleTopics = "visual-arts" msgKey = "wikimedia-articletopics-topic-art", - articleTopics = "visual-arts", + queryTopicId = "visual-arts", displayTitle = "Art" ), OnboardingTopic( topicId = "earth-and-environment", // registry uses "earth-and-environment" as topicId but articleTopics = "geographical" msgKey = "wikimedia-articletopics-topic-earth-and-environment", - articleTopics = "geographical", + queryTopicId = "geographical", displayTitle = "Geographical" ), OnboardingTopic( topicId = "africa", msgKey = "wikimedia-articletopics-topic-africa", - articleTopics = "africa", + queryTopicId = "africa", displayTitle = "Africa" ), OnboardingTopic( topicId = "asia", msgKey = "wikimedia-articletopics-topic-asia", - articleTopics = "asia", + queryTopicId = "asia", displayTitle = "Asia" ), OnboardingTopic( topicId = "europe", msgKey = "wikimedia-articletopics-topic-europe", - articleTopics = "europe", + queryTopicId = "europe", displayTitle = "Europe" ), OnboardingTopic( topicId = "oceania", msgKey = "wikimedia-articletopics-topic-oceania", - articleTopics = "oceania", + queryTopicId = "oceania", displayTitle = "Oceania" ), OnboardingTopic( topicId = "business-and-economics", msgKey = "wikimedia-articletopics-topic-business-and-economics", - articleTopics = "business-and-economics", + queryTopicId = "business-and-economics", displayTitle = "Business and economics" ), OnboardingTopic( topicId = "education", msgKey = "wikimedia-articletopics-topic-education", - articleTopics = "education", + queryTopicId = "education", displayTitle = "Education" ), OnboardingTopic( topicId = "military-and-warfare", msgKey = "wikimedia-articletopics-topic-military-and-warfare", - articleTopics = "military-and-warfare", + queryTopicId = "military-and-warfare", displayTitle = "Military and warfare" ), OnboardingTopic( topicId = "politics-and-government", msgKey = "wikimedia-articletopics-topic-politics-and-government", - articleTopics = "politics-and-government", + queryTopicId = "politics-and-government", displayTitle = "Politics and government" ), OnboardingTopic( topicId = "society", msgKey = "wikimedia-articletopics-topic-society", - articleTopics = "society", + queryTopicId = "society", displayTitle = "Society" ), OnboardingTopic( topicId = "transportation", msgKey = "wikimedia-articletopics-topic-transportation", - articleTopics = "transportation", + queryTopicId = "transportation", displayTitle = "Transportation" ), OnboardingTopic( topicId = "general-science", // registry uses "general-science" as topicId but articleTopics = "stem" msgKey = "wikimedia-articletopics-topic-general-science", - articleTopics = "stem", + queryTopicId = "stem", displayTitle = "STEM" ), OnboardingTopic( topicId = "central-america", msgKey = "wikimedia-articletopics-topic-central-america", - articleTopics = "central-america", + queryTopicId = "central-america", displayTitle = "Central America" ), OnboardingTopic( topicId = "north-america", msgKey = "wikimedia-articletopics-topic-north-america", - articleTopics = "north-america", + queryTopicId = "north-america", displayTitle = "North America" ), OnboardingTopic( topicId = "south-america", msgKey = "wikimedia-articletopics-topic-south-america", - articleTopics = "south-america", + queryTopicId = "south-america", displayTitle = "South America" ), OnboardingTopic( topicId = "architecture", msgKey = "wikimedia-articletopics-topic-architecture", - articleTopics = "architecture", + queryTopicId = "architecture", displayTitle = "Architecture" ), OnboardingTopic( topicId = "comics-and-anime", msgKey = "wikimedia-articletopics-topic-comics-and-anime", - articleTopics = "comics-and-anime", + queryTopicId = "comics-and-anime", displayTitle = "Comics and Anime" ), OnboardingTopic( topicId = "entertainment", msgKey = "wikimedia-articletopics-topic-entertainment", - articleTopics = "entertainment", + queryTopicId = "entertainment", displayTitle = "Entertainment" ), OnboardingTopic( topicId = "fashion", msgKey = "wikimedia-articletopics-topic-fashion", - articleTopics = "fashion", + queryTopicId = "fashion", displayTitle = "Fashion" ), OnboardingTopic( topicId = "music", msgKey = "wikimedia-articletopics-topic-music", - articleTopics = "music", + queryTopicId = "music", displayTitle = "Music" ), OnboardingTopic( topicId = "tv-and-film", msgKey = "wikimedia-articletopics-topic-tv-and-film", - articleTopics = "films", + queryTopicId = "films", displayTitle = "TV and Film" ), OnboardingTopic( topicId = "video-games", msgKey = "wikimedia-articletopics-topic-video-games", - articleTopics = "video-games", + queryTopicId = "video-games", displayTitle = "Video Games" ), OnboardingTopic( topicId = "women", msgKey = "wikimedia-articletopics-topic-women", - articleTopics = "women", + queryTopicId = "women", displayTitle = "Women" ), OnboardingTopic( topicId = "biology", msgKey = "wikimedia-articletopics-topic-biology", - articleTopics = "biology", + queryTopicId = "biology", displayTitle = "Biology" ), OnboardingTopic( topicId = "chemistry", msgKey = "wikimedia-articletopics-topic-chemistry", - articleTopics = "chemistry", + queryTopicId = "chemistry", displayTitle = "Chemistry" ), OnboardingTopic( topicId = "engineering", msgKey = "wikimedia-articletopics-topic-engineering", - articleTopics = "engineering", + queryTopicId = "engineering", displayTitle = "Engineering" ), OnboardingTopic( topicId = "mathematics", msgKey = "wikimedia-articletopics-topic-mathematics", - articleTopics = "mathematics", + queryTopicId = "mathematics", displayTitle = "Mathematics" ), OnboardingTopic( topicId = "medicine-and-health", msgKey = "wikimedia-articletopics-topic-medicine-and-health", - articleTopics = "medicine-and-health", + queryTopicId = "medicine-and-health", displayTitle = "Medicine and Health" ), OnboardingTopic( topicId = "physics", msgKey = "wikimedia-articletopics-topic-physics", - articleTopics = "physics", + queryTopicId = "physics", displayTitle = "Physics" ), OnboardingTopic( topicId = "technology", msgKey = "wikimedia-articletopics-topic-technology", - articleTopics = "technology", + queryTopicId = "technology", displayTitle = "Technology" ), ) From 6683a9ba3f06c6feec482080ed5e4805db62bb41 Mon Sep 17 00:00:00 2001 From: williamrai Date: Thu, 9 Apr 2026 17:34:30 -0400 Subject: [PATCH 21/39] - splits Interest.kt into ArticleInterest.kt and TopicInterest.kt - writes DAO for article and topic interest - update PersonalizationViewModel.kt and PersonalizationRepository.kt - updates SelectionBottomBar to use count of both article and topics - code fixes --- .../org/wikipedia/database/AppDatabase.kt | 36 ++++++---- .../InterestOnboardingScreen.kt | 19 +++--- .../PersonalizationRepository.kt | 66 ++++++++++++------- .../personalization/PersonalizationScreen.kt | 7 +- .../personalization/PersonalizationState.kt | 4 +- .../PersonalizationViewModel.kt | 11 +++- .../db/dao/ArticleInterestDao.kt | 20 ++++++ .../personalization/db/dao/InterestDao.kt | 29 -------- .../db/dao/TopicInterestDao.kt | 20 ++++++ .../db/entity/ArticleInterest.kt | 16 +++++ .../personalization/db/entity/Interest.kt | 28 -------- .../db/entity/TopicInterest.kt | 13 ++++ 12 files changed, 156 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt delete mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt delete mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index c10f6d2c758..efea61bed08 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -21,8 +21,10 @@ import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao import org.wikipedia.offline.db.OfflineObject import org.wikipedia.offline.db.OfflineObjectDao -import org.wikipedia.onboarding.personalization.db.dao.InterestDao -import org.wikipedia.onboarding.personalization.db.entity.Interest +import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao +import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao +import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest +import org.wikipedia.onboarding.personalization.db.entity.TopicInterest import org.wikipedia.pageimages.db.PageImage import org.wikipedia.pageimages.db.PageImageDao import org.wikipedia.readinglist.database.ReadingList @@ -58,7 +60,8 @@ const val DATABASE_VERSION = 33 Category::class, DailyGameHistory::class, RecommendedPage::class, - Interest::class + TopicInterest::class, + ArticleInterest::class ], version = DATABASE_VERSION ) @@ -84,7 +87,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun dailyGameHistoryDao(): DailyGameHistoryDao abstract fun recommendedPageDao(): RecommendedPageDao - abstract fun interestDao(): InterestDao + abstract fun topicInterestDao(): TopicInterestDao + abstract fun articleInterestDao(): ArticleInterestDao companion object { val MIGRATION_19_20 = object : Migration(19, 20) { @@ -361,17 +365,21 @@ abstract class AppDatabase : RoomDatabase() { } val MIGRATION_32_33 = object : Migration(32, 33) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS Interests (" + - "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + - "type INTEGER NOT NULL," + + db.execSQL("CREATE TABLE IF NOT EXISTS TopicInterest (" + + "topicId TEXT NOT NULL," + "lang TEXT NOT NULL," + - "namespace INTEGER," + - "topicLabel TEXT," + - "topicKey TEXT," + - "articleApiTitle TEXT," + - "articleDisplayTitle TEXT," + - "articleDescription TEXT," + - "articleThumbUrl TEXT" + + "topicLabel TEXT NOT NULL," + + "queryTopicId TEXT NOT NULL," + + "PRIMARY KEY (topicId, lang)" + + ")") + db.execSQL("CREATE TABLE IF NOT EXISTS ArticleInterest (" + + "apiTitle TEXT NOT NULL," + + "lang TEXT NOT NULL," + + "namespace INTEGER NOT NULL," + + "displayTitle TEXT NOT NULL," + + "description TEXT NOT NULL," + + "thumbUrl TEXT NOT NULL," + + "PRIMARY KEY (apiTitle, lang, namespace)" + ")") } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index e8cc18af792..615a0b27f4b 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -66,6 +66,7 @@ fun InterestOnboardingScreen( onSearchClick: () -> Unit, onDeselectAllClick: () -> Unit, retryLoading: () -> Unit, + totalSelectedCount: Int, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState() ) { val transition = rememberInfiniteTransition(label = "shimmerTransition") @@ -184,15 +185,14 @@ fun InterestOnboardingScreen( } ) } - if (articlesState is ArticlesState.Success) { - SelectionBottomBar( - modifier = Modifier - .align(Alignment.BottomStart) - .background(WikipediaTheme.colors.paperColor), - selectedItemsCount = articlesState.selectedArticles.size, - onDeselectAllClick = onDeselectAllClick - ) - } + + SelectionBottomBar( + modifier = Modifier + .align(Alignment.BottomStart) + .background(WikipediaTheme.colors.paperColor), + selectedItemsCount = totalSelectedCount, + onDeselectAllClick = onDeselectAllClick + ) } } @@ -338,6 +338,7 @@ private fun InterestOnboardingScreenPreview() { .fillMaxSize() .background(WikipediaTheme.colors.paperColor) .padding(top = 40.dp), + totalSelectedCount = 0, topicsState = TopicsState.Success( topics = OnboardingTopics.all.map { it.copy(displayTitle = it.msgKey, isSelected = it.topicId == "science") diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index 01449650ad9..f49f5fb7f4e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -3,9 +3,10 @@ package org.wikipedia.onboarding.personalization import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.history.db.HistoryEntryWithImageDao -import org.wikipedia.onboarding.personalization.db.dao.InterestDao -import org.wikipedia.onboarding.personalization.db.entity.Interest -import org.wikipedia.onboarding.personalization.db.entity.InterestType +import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao +import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao +import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest +import org.wikipedia.onboarding.personalization.db.entity.TopicInterest import org.wikipedia.onboarding.personalization.topics.OnboardingTopics import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle @@ -14,7 +15,8 @@ import org.wikipedia.readinglist.db.ReadingListPageDao import org.wikipedia.util.StringUtil class PersonalizationRepository( - private val interestDao: InterestDao, + private val topicInterestDao: TopicInterestDao, + private val articleInterestDao: ArticleInterestDao, private val historyEntryWithImageDao: HistoryEntryWithImageDao, private val readingListPageDao: ReadingListPageDao, val wikiSite: WikiSite @@ -100,43 +102,57 @@ class PersonalizationRepository( } suspend fun saveTopic(topic: OnboardingTopic, lang: String) { - interestDao.insert( - Interest( - topicKey = topic.topicId, + topicInterestDao.insert( + topicInterest = TopicInterest( + topicId = topic.topicId, topicLabel = topic.displayTitle, - lang = lang, - type = InterestType.TOPIC.value + queryTopicId = topic.queryTopicId, + lang = lang + )) + } + + suspend fun deleteTopic(topic: OnboardingTopic, lang: String) { + topicInterestDao.delete( + topicInterest = TopicInterest( + topicId = topic.topicId, + topicLabel = topic.displayTitle, + queryTopicId = topic.queryTopicId, + lang = lang ) ) } - suspend fun deleteTopic(topic: OnboardingTopic, lang: String) { - interestDao.findTopic(topic.topicId, lang)?.let { - interestDao.delete(it) - } + suspend fun deleteAllTopics() { + topicInterestDao.deleteAll() } suspend fun saveArticle(article: PageTitle, lang: String) { - interestDao.insert( - Interest( - type = InterestType.ARTICLE.value, + articleInterestDao.insert( + articleInterest = ArticleInterest( + apiTitle = article.prefixedText, lang = lang, namespace = article.namespace(), - articleApiTitle = article.prefixedText, - articleDisplayTitle = article.displayText, - articleDescription = article.description, - articleThumbUrl = article.thumbUrl + displayTitle = article.displayText, + description = article.description.orEmpty(), + thumbUrl = article.thumbUrl.orEmpty() ) ) } suspend fun deleteArticle(article: PageTitle, lang: String) { - interestDao.findArticle(article.prefixedText, lang)?.let { - interestDao.delete(it) - } + articleInterestDao.delete( + articleInterest = ArticleInterest( + apiTitle = article.prefixedText, + lang = lang, + namespace = article.namespace(), + displayTitle = article.displayText, + description = article.description.orEmpty(), + thumbUrl = article.thumbUrl.orEmpty() + ) + ) } - suspend fun deleteAllArticles(lang: String) { - interestDao.deleteAllByType(InterestType.ARTICLE.value, lang) + suspend fun deleteAllArticles() { + articleInterestDao.deleteAll() } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 972cb7b43a8..baf866a17c4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -43,7 +43,7 @@ fun PersonalizationScreen( viewModel: PersonalizationViewModel ) { val coroutineScope = rememberCoroutineScope() - val uiState = viewModel.interestUiState.collectAsState() + val interestUiState = viewModel.interestUiState.collectAsState() val pagerState = rememberPagerState(pageCount = { screens.size }) LaunchedEffect(pagerState.currentPage) { @@ -85,8 +85,9 @@ fun PersonalizationScreen( .fillMaxSize() .background(WikipediaTheme.colors.paperColor) .padding(top = 40.dp), - topicsState = uiState.value.topicsState, - articlesState = uiState.value.articlesState, + topicsState = interestUiState.value.topicsState, + articlesState = interestUiState.value.articlesState, + totalSelectedCount = interestUiState.value.totalSelectedCount, onTopicSelected = { viewModel.onTopicSelected(it) }, diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 0f1156adcfb..7016fc26e1a 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -2,7 +2,6 @@ package org.wikipedia.onboarding.personalization import org.wikipedia.page.PageTitle -// TODO: update the states below as needed as we build out the screen data class OnboardingTopic( val topicId: String, val msgKey: String, @@ -13,7 +12,8 @@ data class OnboardingTopic( data class InterestUiState( val topicsState: TopicsState = TopicsState.Loading, - val articlesState: ArticlesState = ArticlesState.Loading + val articlesState: ArticlesState = ArticlesState.Loading, + val totalSelectedCount: Int = 0 ) sealed interface TopicsState { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index bb4e54ca26e..b28d4f41042 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -57,7 +57,8 @@ private data class PersonalizedViewModelState( articles = articles, selectedArticles = selectedArticles ) - } + }, + totalSelectedCount = selectedTopics.size + selectedArticles.size ) } @@ -212,10 +213,13 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.deleteAllArticles(lang = repository.wikiSite.languageCode) + repository.deleteAllTopics() + repository.deleteAllArticles() + state.update { it.copy( selectedArticles = emptySet(), + selectedTopics = emptyList(), articlesLoading = false, articlesError = null ) @@ -237,7 +241,8 @@ class PersonalizationViewModel( initializer { PersonalizationViewModel( repository = PersonalizationRepository( - interestDao = AppDatabase.instance.interestDao(), + topicInterestDao = AppDatabase.instance.topicInterestDao(), + articleInterestDao = AppDatabase.instance.articleInterestDao(), historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), readingListPageDao = AppDatabase.instance.readingListPageDao(), wikiSite = WikipediaApp.instance.wikiSite diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt new file mode 100644 index 00000000000..b43094be6eb --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt @@ -0,0 +1,20 @@ +package org.wikipedia.onboarding.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest + +@Dao +interface ArticleInterestDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(articleInterest: ArticleInterest) + + @Delete + suspend fun delete(articleInterest: ArticleInterest) + + @Query("DELETE FROM ArticleInterest") + suspend fun deleteAll() +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt deleted file mode 100644 index 9e367080958..00000000000 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestDao.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.wikipedia.onboarding.personalization.db.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query -import kotlinx.coroutines.flow.Flow -import org.wikipedia.onboarding.personalization.db.entity.Interest - -@Dao -interface InterestDao { - @Query("SELECT * FROM Interests WHERE type = :type AND lang = :lang") - fun getByType(type: Int, lang: String): Flow> - - @Insert - suspend fun insert(interest: Interest) - - @Query("SELECT * FROM Interests WHERE topicKey = :topicId AND lang = :lang LIMIT 1") - suspend fun findTopic(topicId: String, lang: String): Interest? - - @Query("SELECT * FROM Interests WHERE articleApiTitle = :articleApiTitle AND lang = :lang LIMIT 1") - suspend fun findArticle(articleApiTitle: String, lang: String): Interest? - - @Delete - suspend fun delete(interest: Interest) - - @Query("DELETE FROM Interests WHERE type = :type AND lang = :lang") - suspend fun deleteAllByType(type: Int, lang: String) -} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt new file mode 100644 index 00000000000..966471d4d22 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt @@ -0,0 +1,20 @@ +package org.wikipedia.onboarding.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wikipedia.onboarding.personalization.db.entity.TopicInterest + +@Dao +interface TopicInterestDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(topicInterest: TopicInterest) + + @Delete + suspend fun delete(topicInterest: TopicInterest) + + @Query("DELETE FROM TopicInterest") + suspend fun deleteAll() +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt new file mode 100644 index 00000000000..c463fe7489e --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt @@ -0,0 +1,16 @@ +package org.wikipedia.onboarding.personalization.db.entity + +import androidx.room.Entity +import org.wikipedia.page.Namespace + +@Entity( + primaryKeys = ["apiTitle", "lang", "namespace"] +) +data class ArticleInterest( + var apiTitle: String, + val lang: String, + val namespace: Namespace, + var displayTitle: String, + var description: String, + var thumbUrl: String +) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt deleted file mode 100644 index 9d764d54b0a..00000000000 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/Interest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.wikipedia.onboarding.personalization.db.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey -import org.wikipedia.page.Namespace - -@Entity(tableName = "Interests") -data class Interest( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val type: Int, // 0 = topic, 1 = article, use InterestType enum for better readability - val lang: String, - val namespace: Namespace? = null, - val topicLabel: String? = null, - val topicKey: String? = null, - var articleApiTitle: String? = null, - var articleDisplayTitle: String? = null, - var articleDescription: String? = null, - var articleThumbUrl: String? = null, -) - -enum class InterestType(val value: Int) { - TOPIC(0), - ARTICLE(1); - - companion object { - fun fromValue(value: Int) = entries.first { it.value == value } - } -} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt new file mode 100644 index 00000000000..394b6489837 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt @@ -0,0 +1,13 @@ +package org.wikipedia.onboarding.personalization.db.entity + +import androidx.room.Entity + +@Entity( + primaryKeys = ["topicId", "lang"] +) +data class TopicInterest( + val topicId: String, + val lang: String, + val topicLabel: String, + val queryTopicId: String +) From 3d0a190093033f9a8ff0d7ec9b0c84581e41fce9 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 10 Apr 2026 13:42:27 -0400 Subject: [PATCH 22/39] - maps one to many relationship between article and topic interest - adds job in PersonalizationViewModel.kt - code fixes --- .../org/wikipedia/database/AppDatabase.kt | 18 ++++--- .../PersonalizationRepository.kt | 21 ++++---- .../personalization/PersonalizationScreen.kt | 2 +- .../PersonalizationViewModel.kt | 51 +++++++++++-------- .../db/entity/ArticleInterest.kt | 25 +++++++-- 5 files changed, 74 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index efea61bed08..cdbf7d13ad5 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -373,14 +373,18 @@ abstract class AppDatabase : RoomDatabase() { "PRIMARY KEY (topicId, lang)" + ")") db.execSQL("CREATE TABLE IF NOT EXISTS ArticleInterest (" + - "apiTitle TEXT NOT NULL," + - "lang TEXT NOT NULL," + - "namespace INTEGER NOT NULL," + - "displayTitle TEXT NOT NULL," + - "description TEXT NOT NULL," + - "thumbUrl TEXT NOT NULL," + - "PRIMARY KEY (apiTitle, lang, namespace)" + + "apiTitle TEXT NOT NULL," + + "lang TEXT NOT NULL," + + "namespace INTEGER NOT NULL," + + "displayTitle TEXT NOT NULL," + + "description TEXT NOT NULL," + + "thumbUrl TEXT NOT NULL," + + "topicId TEXT," + + "topicLang TEXT," + + "PRIMARY KEY (apiTitle, lang, namespace)," + + "FOREIGN KEY(topicId, lang) REFERENCES TopicInterest(topicId, lang) ON DELETE SET NULL ON UPDATE CASCADE" + ")") + db.execSQL("CREATE INDEX IF NOT EXISTS index_ArticleInterest_topicId_lang ON ArticleInterest (topicId, lang)") } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index f49f5fb7f4e..852e4a88ec1 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -1,5 +1,6 @@ package org.wikipedia.onboarding.personalization +import androidx.room.Transaction import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.history.db.HistoryEntryWithImageDao @@ -122,11 +123,7 @@ class PersonalizationRepository( ) } - suspend fun deleteAllTopics() { - topicInterestDao.deleteAll() - } - - suspend fun saveArticle(article: PageTitle, lang: String) { + suspend fun saveArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { articleInterestDao.insert( articleInterest = ArticleInterest( apiTitle = article.prefixedText, @@ -134,12 +131,14 @@ class PersonalizationRepository( namespace = article.namespace(), displayTitle = article.displayText, description = article.description.orEmpty(), - thumbUrl = article.thumbUrl.orEmpty() + thumbUrl = article.thumbUrl.orEmpty(), + topicId = topic?.topicId, + topicLang = lang ) ) } - suspend fun deleteArticle(article: PageTitle, lang: String) { + suspend fun deleteArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { articleInterestDao.delete( articleInterest = ArticleInterest( apiTitle = article.prefixedText, @@ -147,12 +146,16 @@ class PersonalizationRepository( namespace = article.namespace(), displayTitle = article.displayText, description = article.description.orEmpty(), - thumbUrl = article.thumbUrl.orEmpty() + thumbUrl = article.thumbUrl.orEmpty(), + topicId = topic?.topicId, + topicLang = lang ) ) } - suspend fun deleteAllArticles() { + @Transaction + suspend fun deleteAllInterests() { + topicInterestDao.deleteAll() articleInterestDao.deleteAll() } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index baf866a17c4..b02a2259fd8 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -92,7 +92,7 @@ fun PersonalizationScreen( viewModel.onTopicSelected(it) }, onItemClick = { - viewModel.toggleSelection(it) + viewModel.toggleArticleSelection(it) }, onSearchClick = onSearchClick, onDeselectAllClick = { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index b28d4f41042..e20d2677f92 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map @@ -71,6 +72,7 @@ class PersonalizationViewModel( ) : 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()) + private var articlesJob: Job? = null // Each screen observes only its own derived UI state // runs automatically when any part of the raw state changes @@ -104,7 +106,8 @@ class PersonalizationViewModel( } private fun loadInitialArticles() { - viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + articlesJob?.cancel() + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { state.update { it.copy(articlesLoading = true, articlesError = null) } @@ -121,13 +124,14 @@ class PersonalizationViewModel( } } - private fun loadArticlesByTopic(topic: String) { - viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + private fun loadArticlesByTopic(topic: OnboardingTopic) { + articlesJob?.cancel() + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { state.update { it.copy(articlesLoading = true, articlesError = null) } - val articles = repository.getArticlesByTopic(topic) + val articles = repository.getArticlesByTopic(topic.queryTopicId) state.update { current -> val newArticles = (current.selectedArticles.toList() + articles).distinct() current.copy(articles = newArticles, articlesLoading = false) @@ -138,16 +142,18 @@ class PersonalizationViewModel( // as we have a single state it becomes easier to update and control the state fun onTopicSelected(topic: OnboardingTopic) { val lang = repository.wikiSite.languageCode - val isSelected = state.value.selectedTopics.any { selected -> selected.topicId == topic.topicId } // When a category is selected, we want to reset the articles state and load articles for the selected category viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(topicsError = throwable) } }) { + val currentTopics = state.value.selectedTopics + val isSelected = currentTopics.any { selected -> selected.topicId == topic.topicId } + val selectedTopics = if (isSelected) { - state.value.selectedTopics.filter { it.topicId != topic.topicId } + currentTopics.filter { it.topicId != topic.topicId } } else { - state.value.selectedTopics + topic + currentTopics + topic } if (isSelected) { @@ -165,8 +171,8 @@ class PersonalizationViewModel( ) } - val topicQueryId = selectedTopics.lastOrNull()?.queryTopicId - if (topicQueryId == null) loadInitialArticles() else loadArticlesByTopic(topic = topicQueryId) + val lastSelectedTopic = selectedTopics.lastOrNull() + if (lastSelectedTopic == null) loadInitialArticles() else loadArticlesByTopic(topic = lastSelectedTopic) } } @@ -175,7 +181,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.saveArticle(title, repository.wikiSite.languageCode) + repository.saveArticle(title, repository.wikiSite.languageCode, null) state.update { val newItems = listOf(title) + it.articles val newSelection = it.selectedArticles + title @@ -184,24 +190,28 @@ class PersonalizationViewModel( } } - fun toggleSelection(title: PageTitle) { + fun toggleArticleSelection(title: PageTitle) { val lang = repository.wikiSite.languageCode - val isSelected = state.value.selectedArticles.contains(title) + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesError = throwable) } }) { + val current = state.value + val isSelected = current.selectedArticles.contains(title) + val currentSelectedTopic = current.selectedTopics.lastOrNull() + if (isSelected) { - repository.deleteArticle(title, lang) + repository.deleteArticle(title, lang, currentSelectedTopic) } else { - repository.saveArticle(title, lang) + repository.saveArticle(title, lang, currentSelectedTopic) } - state.update { current -> - current.copy( + state.update { currentState -> + currentState.copy( selectedArticles = if (isSelected) { - current.selectedArticles - title + currentState.selectedArticles - title } else { - current.selectedArticles + title + currentState.selectedArticles + title } ) } @@ -213,8 +223,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.deleteAllTopics() - repository.deleteAllArticles() + repository.deleteAllInterests() state.update { it.copy( @@ -230,7 +239,7 @@ class PersonalizationViewModel( fun retryLoading() { val last = state.value.selectedTopics.lastOrNull() if (last != null) { - loadArticlesByTopic(topic = last.queryTopicId) + loadArticlesByTopic(topic = last) } else { loadInitialArticles() } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt index c463fe7489e..296b49cb64e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt @@ -1,16 +1,31 @@ package org.wikipedia.onboarding.personalization.db.entity import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index import org.wikipedia.page.Namespace @Entity( - primaryKeys = ["apiTitle", "lang", "namespace"] + primaryKeys = ["apiTitle", "lang", "namespace"], + foreignKeys = [ + ForeignKey( + entity = TopicInterest::class, + parentColumns = ["topicId", "lang"], // primary key in the parent entity + childColumns = ["topicId", "topicLang"], // foreign key in this entity which references the primary key in parent entity + onDelete = ForeignKey.SET_NULL, // when a topic is deleted, the foreign key in this entity will be set to null to not delete the article interest but just disassociate it from the deleted topic + onUpdate = ForeignKey.CASCADE // when a topic's primary key is updated, the foreign key in this entity will also be updated + ) + ], + indices = [Index(value = ["topicId", "lang"])] // index for the foreign key columns to improve query performance especially for cascade operations ) data class ArticleInterest( - var apiTitle: String, + val apiTitle: String, val lang: String, val namespace: Namespace, - var displayTitle: String, - var description: String, - var thumbUrl: String + val displayTitle: String, + val description: String, + val thumbUrl: String, + // foreign key referencing the topicId, lang in TopicInterest + val topicId: String? = null, + val topicLang: String? = null ) From 31086baaf74851ce9d67a0be37f929a6171d5dbb Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 10 Apr 2026 17:04:19 -0400 Subject: [PATCH 23/39] - adds code to restore persisted interest for returning user - update article and topic interest dao - code fixes --- .../PersonalizationRepository.kt | 18 +++++ .../PersonalizationViewModel.kt | 72 +++++++++++++++---- .../db/dao/ArticleInterestDao.kt | 3 + .../db/dao/TopicInterestDao.kt | 3 + 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index 852e4a88ec1..cbbe273515d 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -102,6 +102,24 @@ class PersonalizationRepository( return results.distinctBy { it.prefixedText } } + suspend fun getPersistedTopics(lang: String): List { + return topicInterestDao.getAll(lang).mapNotNull { entity -> + OnboardingTopics.all.find { it.topicId == entity.topicId } + } + } + + suspend fun getPersistedArticles(lang: String): List { + return articleInterestDao.getAll(lang).map { entity -> + PageTitle( + text = entity.apiTitle, + wiki = wikiSite, + thumbUrl = entity.thumbUrl, + description = entity.description, + displayText = entity.displayTitle + ) + } + } + suspend fun saveTopic(topic: OnboardingTopic, lang: String) { topicInterestDao.insert( topicInterest = TopicInterest( diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index e20d2677f92..21f6e073f67 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -16,6 +16,7 @@ import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs +import org.wikipedia.util.log.L // this is a raw, flat, internal representation of ALL state // needed across the personalization flow (interest and feed preference) @@ -86,22 +87,64 @@ class PersonalizationViewModel( fun onPageChanged(screen: PersonalizationPage) { when (screen) { - PersonalizationPage.INTERESTS -> { - if (state.value.topics.isEmpty()) loadTopics(repository.wikiSite.languageCode) - if (state.value.articles.isEmpty()) loadInitialArticles() - } + PersonalizationPage.INTERESTS -> loadScreen() else -> {} } } - private fun loadTopics(langCode: String) { - viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> - state.update { it.copy(topicsLoading = false, topicsError = throwable) } - }) { - state.update { it.copy(topicsLoading = true, topicsError = null) } + private fun loadScreen() { + viewModelScope.launch( CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + val topicsLoaded = loadTopics() + if (topicsLoaded) { + initialize() + } + } + } + private suspend fun loadTopics(): Boolean { + if (state.value.topics.isNotEmpty()) return false + + return runCatching { + val langCode = repository.wikiSite.languageCode + state.update { it.copy(topicsLoading = true, topicsError = null) } val topics = repository.getTopics(langCode) state.update { it.copy(topics = topics, topicsLoading = false) } + }.onFailure { throwable -> + state.update { it.copy(topicsLoading = false, topicsError = throwable) } + }.isSuccess + } + + private suspend fun initialize() { + runCatching { + val langCode = repository.wikiSite.languageCode + // check db for persisted interest (topic and articles) data + val persistedTopics = repository.getPersistedTopics(langCode) + val persistedArticles = repository.getPersistedArticles(langCode) + + val hasPersistedData = persistedTopics.isNotEmpty() || persistedArticles.isNotEmpty() + if (!hasPersistedData && state.value.articles.isEmpty()) { + loadInitialArticles() + return@runCatching + } + + // restore selections + state.update { current -> + current.copy( + selectedTopics = persistedTopics, + selectedArticles = persistedArticles.toSet() + ) + } + + val lasTopic = persistedTopics.lastOrNull() + if (lasTopic != null) { + loadArticlesByTopic(topic = lasTopic) + } else { + loadInitialArticles() + } + }.onFailure { throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } } } @@ -114,11 +157,11 @@ class PersonalizationViewModel( val selectedItems = Prefs.recommendedReadingListInterests val articles = repository.loadInitialArticles(selectedItems) - state.update { - it.copy( - articles = articles, - articlesLoading = false, - selectedArticles = selectedItems.toSet() + state.update { current -> + val newArticles = (current.selectedArticles + articles).distinct() + current.copy( + articles = newArticles, + articlesLoading = false ) } } @@ -166,7 +209,6 @@ class PersonalizationViewModel( current.copy( selectedTopics = selectedTopics, articles = emptyList(), - articlesLoading = true, articlesError = null ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt index b43094be6eb..5084d91fa78 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt @@ -17,4 +17,7 @@ interface ArticleInterestDao { @Query("DELETE FROM ArticleInterest") suspend fun deleteAll() + + @Query("SELECT * FROM ArticleInterest WHERE lang = :lang") + suspend fun getAll(lang: String): List } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt index 966471d4d22..2208a00e1e4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt @@ -17,4 +17,7 @@ interface TopicInterestDao { @Query("DELETE FROM TopicInterest") suspend fun deleteAll() + + @Query("SELECT * FROM TopicInterest WHERE lang = :lang") + suspend fun getAll(lang: String): List } From 3db7658dce2a10fcc2c5739441e3f8e387fb702a Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 10 Apr 2026 17:34:46 -0400 Subject: [PATCH 24/39] - adds showError - code fixes --- .../personalization/InterestOnboardingScreen.kt | 12 ++++-------- .../personalization/PersonalizationActivity.kt | 4 ++++ .../personalization/PersonalizationScreen.kt | 4 +++- .../personalization/PersonalizationState.kt | 2 +- .../personalization/PersonalizationViewModel.kt | 12 +++++------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index 615a0b27f4b..fd4dac0daf2 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -66,6 +66,7 @@ fun InterestOnboardingScreen( onSearchClick: () -> Unit, onDeselectAllClick: () -> Unit, retryLoading: () -> Unit, + showError: (Throwable) -> Unit, totalSelectedCount: Int, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState() ) { @@ -100,13 +101,7 @@ fun InterestOnboardingScreen( item(span = StaggeredGridItemSpan.FullLine) { when (topicsState) { is TopicsState.Error -> { - Text( - modifier = Modifier - .fillMaxWidth(), - text = topicsState.message, - style = MaterialTheme.typography.bodyMedium, - color = WikipediaTheme.colors.secondaryColor - ) + showError(topicsState.message) } TopicsState.Loading -> { LazyRow( @@ -351,7 +346,8 @@ private fun InterestOnboardingScreenPreview() { onTopicSelected = {}, onSearchClick = {}, onDeselectAllClick = {}, - retryLoading = {} + retryLoading = {}, + showError = {} ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index e56d7ddec39..f056f760e51 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -12,6 +12,7 @@ import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.extensions.parcelableExtra import org.wikipedia.page.PageTitle import org.wikipedia.search.SearchActivity +import org.wikipedia.util.FeedbackUtil class PersonalizationActivity : BaseActivity() { @@ -40,6 +41,9 @@ class PersonalizationActivity : BaseActivity() { onSearchClick = { val intent = SearchActivity.newIntent(this, Constants.InvokeSource.INTEREST_SELECTION, null, returnLink = true) searchLauncher.launch(intent) + }, + showError = { message -> + FeedbackUtil.showError(this, message) } ) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index b02a2259fd8..4c093e38b81 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -40,6 +40,7 @@ fun PersonalizationScreen( screens: List, onSkipClick: () -> Unit, onSearchClick: () -> Unit, + showError: (Throwable) -> Unit, viewModel: PersonalizationViewModel ) { val coroutineScope = rememberCoroutineScope() @@ -100,7 +101,8 @@ fun PersonalizationScreen( }, retryLoading = { viewModel.retryLoading() - } + }, + showError = showError ) } PersonalizationPage.FEED_PREFERENCE -> { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 7016fc26e1a..5f85be0bc6d 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -19,7 +19,7 @@ data class InterestUiState( sealed interface TopicsState { data object Loading : TopicsState data class Success(val topics: List) : TopicsState - data class Error(val message: String) : TopicsState + data class Error(val message: Throwable) : TopicsState } sealed interface ArticlesState { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index 21f6e073f67..c0c32659610 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -39,9 +39,7 @@ private data class PersonalizedViewModelState( return InterestUiState( topicsState = when { topicsLoading -> TopicsState.Loading - topicsError != null -> TopicsState.Error( - topicsError.message ?: "Unknown error" - ) + topicsError != null -> TopicsState.Error(topicsError) else -> TopicsState.Success( topics = topics.map { @@ -51,9 +49,7 @@ private data class PersonalizedViewModelState( }, articlesState = when { articlesLoading -> ArticlesState.Loading - articlesError != null -> ArticlesState.Error( - articlesError - ) + articlesError != null -> ArticlesState.Error(articlesError) else -> ArticlesState.Success( articles = articles, @@ -107,9 +103,11 @@ class PersonalizationViewModel( if (state.value.topics.isNotEmpty()) return false return runCatching { - val langCode = repository.wikiSite.languageCode state.update { it.copy(topicsLoading = true, topicsError = null) } + + val langCode = repository.wikiSite.languageCode val topics = repository.getTopics(langCode) + state.update { it.copy(topics = topics, topicsLoading = false) } }.onFailure { throwable -> state.update { it.copy(topicsLoading = false, topicsError = throwable) } From 2c13fbe80082a197bc7f960ca194b4fcfdd5909e Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 13 Apr 2026 10:00:20 -0400 Subject: [PATCH 25/39] - code fixes - updates db entities and adds unit test --- .../PersonalizationViewModel.kt | 18 +-- .../db/dao/ArticleInterestDao.kt | 4 + .../db/entity/ArticleInterest.kt | 3 +- .../database/ArticleInterestDaoTest.kt | 123 ++++++++++++++++++ 4 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index c0c32659610..fa9bf820bc4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -92,17 +92,15 @@ class PersonalizationViewModel( viewModelScope.launch( CoroutineExceptionHandler { _, throwable -> L.e(throwable) }) { - val topicsLoaded = loadTopics() - if (topicsLoaded) { - initialize() - } + loadTopics() + initialize() } } - private suspend fun loadTopics(): Boolean { - if (state.value.topics.isNotEmpty()) return false + private suspend fun loadTopics() { + if (state.value.topics.isNotEmpty()) return - return runCatching { + runCatching { state.update { it.copy(topicsLoading = true, topicsError = null) } val langCode = repository.wikiSite.languageCode @@ -111,7 +109,7 @@ class PersonalizationViewModel( state.update { it.copy(topics = topics, topicsLoading = false) } }.onFailure { throwable -> state.update { it.copy(topicsLoading = false, topicsError = throwable) } - }.isSuccess + } } private suspend fun initialize() { @@ -147,7 +145,9 @@ class PersonalizationViewModel( } private fun loadInitialArticles() { + if (state.value.articles.isNotEmpty()) return articlesJob?.cancel() + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { @@ -166,7 +166,9 @@ class PersonalizationViewModel( } private fun loadArticlesByTopic(topic: OnboardingTopic) { + if (state.value.articles.isNotEmpty()) return articlesJob?.cancel() + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesLoading = false, articlesError = throwable) } }) { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt index 5084d91fa78..556bc46bb67 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest +import org.wikipedia.page.Namespace @Dao interface ArticleInterestDao { @@ -20,4 +21,7 @@ interface ArticleInterestDao { @Query("SELECT * FROM ArticleInterest WHERE lang = :lang") suspend fun getAll(lang: String): List + + @Query("UPDATE ArticleInterest SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") + suspend fun updateTopic(newTopicId: String, apiTitle: String, lang: String, namespace: Namespace) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt index 296b49cb64e..07df9a72101 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt @@ -13,10 +13,9 @@ import org.wikipedia.page.Namespace parentColumns = ["topicId", "lang"], // primary key in the parent entity childColumns = ["topicId", "topicLang"], // foreign key in this entity which references the primary key in parent entity onDelete = ForeignKey.SET_NULL, // when a topic is deleted, the foreign key in this entity will be set to null to not delete the article interest but just disassociate it from the deleted topic - onUpdate = ForeignKey.CASCADE // when a topic's primary key is updated, the foreign key in this entity will also be updated ) ], - indices = [Index(value = ["topicId", "lang"])] // index for the foreign key columns to improve query performance especially for cascade operations + indices = [Index(value = ["topicId", "topicLang"])] // index for the foreign key columns to improve query performance especially for cascade operations ) data class ArticleInterest( val apiTitle: String, diff --git a/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt b/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt new file mode 100644 index 00000000000..0c40b4fe0da --- /dev/null +++ b/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt @@ -0,0 +1,123 @@ +package org.wikipedia.database + +import androidx.room.Room +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao +import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao +import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest +import org.wikipedia.onboarding.personalization.db.entity.TopicInterest +import org.wikipedia.page.Namespace + +@RunWith(RobolectricTestRunner::class) +class ArticleInterestDaoTest { + private lateinit var db: AppDatabase + private lateinit var articleInterestDao: ArticleInterestDao + private lateinit var topicInterestDao: TopicInterestDao + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication().applicationContext + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + articleInterestDao = db.articleInterestDao() + topicInterestDao = db.topicInterestDao() + } + + @Test + fun insert_and_getAll_byLang() = runBlocking { + val article = ArticleInterest(apiTitle = "Dog", lang = "en", namespace = Namespace.MAIN, displayTitle = "Dog", description = "Animal", thumbUrl = "") + articleInterestDao.insert(article) + + val results = articleInterestDao.getAll("en") + assert(results.size == 1) + assert(results[0].apiTitle == "Dog") + } + + @Test + fun articleInterest_foreignKey_setNull_onTopicDelete() = runBlocking { + val topic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + topicInterestDao.insert(topic) + + val cookingTopic = TopicInterest(topicId = "Cooking", lang = "en", topicLabel = "Cooking", queryTopicId = "Cooking") + topicInterestDao.insert(cookingTopic) + + val article1 = ArticleInterest(apiTitle = "Football", lang = "en", namespace = Namespace.MAIN, displayTitle = "Football", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + articleInterestDao.insert(article1) + + val article2 = ArticleInterest(apiTitle = "Basketball", lang = "en", namespace = Namespace.MAIN, displayTitle = "Basketball", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + articleInterestDao.insert(article2) + + val article3 = ArticleInterest(apiTitle = "Tennis", lang = "en", namespace = Namespace.MAIN, displayTitle = "Tennis", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + articleInterestDao.insert(article3) + + val article4 = ArticleInterest(apiTitle = "Cricket", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cricket", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + articleInterestDao.insert(article4) + + val article5 = ArticleInterest(apiTitle = "Cooking", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cooking", description = "Hobby", thumbUrl = "", topicId = cookingTopic.topicId, topicLang = cookingTopic.lang) + articleInterestDao.insert(article5) + + // Delete the topic and check that the foreign key in articles is set to null + topicInterestDao.delete(topic) + + val results = articleInterestDao.getAll("en") + assert(results.size == 5) + + val cookingResult = results.first { it.topicId == cookingTopic.topicId } + assertNotNull(cookingResult.topicId) + assertNotNull(cookingResult.topicLang) + + val sportsArticles = results.filter { it.apiTitle != "Cooking" } + sportsArticles.forEach { article -> + assertNull(article.topicId) + assertNull(article.topicLang) + } + } + + @Test + fun updateTopic_reassignsArticleToNewTopic() = runBlocking { + val sportsTopic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + topicInterestDao.insert(sportsTopic) + + val technologyTopic = TopicInterest(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + topicInterestDao.insert(technologyTopic) + + val article = ArticleInterest(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = "sports", topicLang = "en") + articleInterestDao.insert(article) + + articleInterestDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = article.apiTitle, lang = article.lang, namespace = article.namespace) + val results = articleInterestDao.getAll("en").first() + assertTrue(results.topicId == technologyTopic.topicId) + } + + @Test + fun updateTopic_doesNotAffectOtherArticles() = runBlocking { + val sportsTopic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + topicInterestDao.insert(sportsTopic) + + val technologyTopic = TopicInterest(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + topicInterestDao.insert(technologyTopic) + + val article1 = ArticleInterest(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) + val article2 = ArticleInterest(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicLabel, topicLang = sportsTopic.lang) + articleInterestDao.insert(article1) + articleInterestDao.insert(article2) + + articleInterestDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN) + + val results = articleInterestDao.getAll("en") + assert(results.size == 2) + val johnDoeArticle = results.firstOrNull { it.apiTitle == "John Doe" } + val janeDoeArticle = results.firstOrNull { it.apiTitle == "Jane Doe" } + assertTrue(johnDoeArticle?.topicId == technologyTopic.topicId) + assertTrue(janeDoeArticle?.topicId == "sports") + } +} From b067d2ca79a1972f594a7852dc8c38bd3248e4ae Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 13 Apr 2026 10:34:34 -0400 Subject: [PATCH 26/39] - adds new db structure json file --- .../33.json | 876 ++++++++++++++++++ 1 file changed, 876 insertions(+) create mode 100644 app/schemas/org.wikipedia.database.AppDatabase/33.json diff --git a/app/schemas/org.wikipedia.database.AppDatabase/33.json b/app/schemas/org.wikipedia.database.AppDatabase/33.json new file mode 100644 index 00000000000..d52a0d98d91 --- /dev/null +++ b/app/schemas/org.wikipedia.database.AppDatabase/33.json @@ -0,0 +1,876 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "e44473e74bf92e8ad212bbdde5bfb88b", + "entities": [ + { + "tableName": "HistoryEntry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `lang` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `namespace` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `source` INTEGER NOT NULL, `prevId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevId", + "columnName": "prevId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_HistoryEntry_lang_namespace_apiTitle", + "unique": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HistoryEntry_lang_namespace_apiTitle` ON `${TABLE_NAME}` (`lang`, `namespace`, `apiTitle`)" + } + ] + }, + { + "tableName": "PageImage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lang` TEXT NOT NULL, `namespace` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `imageName` TEXT, `description` TEXT, `timeSpentSec` INTEGER NOT NULL, `geoLat` REAL NOT NULL, `geoLon` REAL NOT NULL, PRIMARY KEY(`lang`, `namespace`, `apiTitle`))", + "fields": [ + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageName", + "columnName": "imageName", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "timeSpentSec", + "columnName": "timeSpentSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "geoLat", + "columnName": "geoLat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "geoLon", + "columnName": "geoLon", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ] + }, + "indices": [ + { + "name": "index_PageImage_lang_namespace_apiTitle", + "unique": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageImage_lang_namespace_apiTitle` ON `${TABLE_NAME}` (`lang`, `namespace`, `apiTitle`)" + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`text`))", + "fields": [ + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "text" + ] + } + }, + { + "tableName": "TalkPageSeen", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha` TEXT NOT NULL, PRIMARY KEY(`sha`))", + "fields": [ + { + "fieldPath": "sha", + "columnName": "sha", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sha" + ] + } + }, + { + "tableName": "EditSummary", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`summary` TEXT NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`summary`))", + "fields": [ + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "summary" + ] + } + }, + { + "tableName": "OfflineObject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `lang` TEXT NOT NULL, `path` TEXT NOT NULL, `status` INTEGER NOT NULL, `usedByStr` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedByStr", + "columnName": "usedByStr", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ReadingList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`listTitle` TEXT NOT NULL, `description` TEXT, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sizeBytes` INTEGER NOT NULL, `dirty` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "listTitle", + "columnName": "listTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dirty", + "columnName": "dirty", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ReadingListPage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wiki` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `listId` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `offline` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `lang` TEXT NOT NULL, `revId` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offline", + "columnName": "offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revId", + "columnName": "revId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revid", + "columnName": "revid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "agent", + "columnName": "agent", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "contents", + "columnName": "contents", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "wiki" + ] + } + }, + { + "tableName": "TalkTemplate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `title` TEXT NOT NULL, `lang` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`year`, `month`, `title`, `lang`))", + "fields": [ + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "year", + "month", + "title", + "lang" + ] + } + }, + { + "tableName": "DailyGameHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gameName` INTEGER NOT NULL, `language` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `score` INTEGER NOT NULL, `playType` INTEGER NOT NULL, `gameData` TEXT, `status` INTEGER NOT NULL, `currentQuestionIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameName", + "columnName": "gameName", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playType", + "columnName": "playType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameData", + "columnName": "gameData", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentQuestionIndex", + "columnName": "currentQuestionIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "RecommendedPage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `wiki` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `status` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TopicInterest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `lang` TEXT NOT NULL, `topicLabel` TEXT NOT NULL, `queryTopicId` TEXT NOT NULL, PRIMARY KEY(`topicId`, `lang`))", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicLabel", + "columnName": "topicLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queryTopicId", + "columnName": "queryTopicId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "topicId", + "lang" + ] + } + }, + { + "tableName": "ArticleInterest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`apiTitle` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT NOT NULL, `thumbUrl` TEXT NOT NULL, `topicId` TEXT, `topicLang` TEXT, PRIMARY KEY(`apiTitle`, `lang`, `namespace`), FOREIGN KEY(`topicId`, `topicLang`) REFERENCES `TopicInterest`(`topicId`, `lang`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT" + }, + { + "fieldPath": "topicLang", + "columnName": "topicLang", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "apiTitle", + "lang", + "namespace" + ] + }, + "indices": [ + { + "name": "index_ArticleInterest_topicId_topicLang", + "unique": false, + "columnNames": [ + "topicId", + "topicLang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ArticleInterest_topicId_topicLang` ON `${TABLE_NAME}` (`topicId`, `topicLang`)" + } + ], + "foreignKeys": [ + { + "table": "TopicInterest", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "topicId", + "topicLang" + ], + "referencedColumns": [ + "topicId", + "lang" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e44473e74bf92e8ad212bbdde5bfb88b')" + ] + } +} \ No newline at end of file From 29f4fe10e8da9d87d9c8811693e8be5aa38a305d Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 13 Apr 2026 10:55:28 -0400 Subject: [PATCH 27/39] - fix migration --- app/src/main/java/org/wikipedia/database/AppDatabase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index cdbf7d13ad5..ebdd64fc116 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -382,9 +382,9 @@ abstract class AppDatabase : RoomDatabase() { "topicId TEXT," + "topicLang TEXT," + "PRIMARY KEY (apiTitle, lang, namespace)," + - "FOREIGN KEY(topicId, lang) REFERENCES TopicInterest(topicId, lang) ON DELETE SET NULL ON UPDATE CASCADE" + + "FOREIGN KEY(topicId, topicLang) REFERENCES TopicInterest(topicId, lang) ON DELETE SET NULL" + ")") - db.execSQL("CREATE INDEX IF NOT EXISTS index_ArticleInterest_topicId_lang ON ArticleInterest (topicId, lang)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_ArticleInterest_topicId_topicLang ON ArticleInterest (topicId, topicLang)") } } From 0f67fe61f4ec4514a129cdff108416c0a8d21fc8 Mon Sep 17 00:00:00 2001 From: williamrai Date: Mon, 13 Apr 2026 11:31:37 -0400 Subject: [PATCH 28/39] - fix db unit test --- .../test/java/org/wikipedia/database/ArticleInterestDaoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt b/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt index 0c40b4fe0da..c27b262f205 100644 --- a/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt +++ b/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt @@ -107,7 +107,7 @@ class ArticleInterestDaoTest { topicInterestDao.insert(technologyTopic) val article1 = ArticleInterest(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) - val article2 = ArticleInterest(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicLabel, topicLang = sportsTopic.lang) + val article2 = ArticleInterest(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) articleInterestDao.insert(article1) articleInterestDao.insert(article2) From e82a175160d3af18ad3284cf7829dc27866b9694 Mon Sep 17 00:00:00 2001 From: williamrai Date: Tue, 14 Apr 2026 14:25:46 -0400 Subject: [PATCH 29/39] - code/ui fixes - renames article and topic interest table --- .../33.json | 16 ++-- app/src/main/java/org/wikipedia/Constants.kt | 2 +- .../compose/components/SearchBarCard.kt | 3 +- .../org/wikipedia/database/AppDatabase.kt | 20 ++-- .../InterestOnboardingScreen.kt | 7 +- .../PersonalizationActivity.kt | 2 +- .../PersonalizationRepository.kt | 39 ++++---- .../PersonalizationViewModel.kt | 4 +- ...leInterestDao.kt => InterestArticleDao.kt} | 16 ++-- .../db/dao/InterestTopicDao.kt | 23 +++++ .../db/dao/TopicInterestDao.kt | 23 ----- ...{ArticleInterest.kt => InterestArticle.kt} | 4 +- .../{TopicInterest.kt => InterestTopic.kt} | 2 +- ...RecommendedReadingListInterestsFragment.kt | 5 +- ...stDaoTest.kt => InterestArticleDaoTest.kt} | 92 +++++++++---------- 15 files changed, 133 insertions(+), 125 deletions(-) rename app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/{ArticleInterestDao.kt => InterestArticleDao.kt} (53%) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt delete mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt rename app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/{ArticleInterest.kt => InterestArticle.kt} (94%) rename app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/{TopicInterest.kt => InterestTopic.kt} (90%) rename app/src/test/java/org/wikipedia/database/{ArticleInterestDaoTest.kt => InterestArticleDaoTest.kt} (60%) diff --git a/app/schemas/org.wikipedia.database.AppDatabase/33.json b/app/schemas/org.wikipedia.database.AppDatabase/33.json index d52a0d98d91..8fbfa51525e 100644 --- a/app/schemas/org.wikipedia.database.AppDatabase/33.json +++ b/app/schemas/org.wikipedia.database.AppDatabase/33.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 33, - "identityHash": "e44473e74bf92e8ad212bbdde5bfb88b", + "identityHash": "22530eedc11ebe27fdf14ab275b9ea28", "entities": [ { "tableName": "HistoryEntry", @@ -744,7 +744,7 @@ } }, { - "tableName": "TopicInterest", + "tableName": "InterestTopic", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `lang` TEXT NOT NULL, `topicLabel` TEXT NOT NULL, `queryTopicId` TEXT NOT NULL, PRIMARY KEY(`topicId`, `lang`))", "fields": [ { @@ -781,8 +781,8 @@ } }, { - "tableName": "ArticleInterest", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`apiTitle` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT NOT NULL, `thumbUrl` TEXT NOT NULL, `topicId` TEXT, `topicLang` TEXT, PRIMARY KEY(`apiTitle`, `lang`, `namespace`), FOREIGN KEY(`topicId`, `topicLang`) REFERENCES `TopicInterest`(`topicId`, `lang`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "tableName": "InterestArticle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`apiTitle` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT NOT NULL, `thumbUrl` TEXT NOT NULL, `topicId` TEXT, `topicLang` TEXT, PRIMARY KEY(`apiTitle`, `lang`, `namespace`), FOREIGN KEY(`topicId`, `topicLang`) REFERENCES `InterestTopic`(`topicId`, `lang`) ON UPDATE NO ACTION ON DELETE SET NULL )", "fields": [ { "fieldPath": "apiTitle", @@ -841,19 +841,19 @@ }, "indices": [ { - "name": "index_ArticleInterest_topicId_topicLang", + "name": "index_InterestArticle_topicId_topicLang", "unique": false, "columnNames": [ "topicId", "topicLang" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_ArticleInterest_topicId_topicLang` ON `${TABLE_NAME}` (`topicId`, `topicLang`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_InterestArticle_topicId_topicLang` ON `${TABLE_NAME}` (`topicId`, `topicLang`)" } ], "foreignKeys": [ { - "table": "TopicInterest", + "table": "InterestTopic", "onDelete": "SET NULL", "onUpdate": "NO ACTION", "columns": [ @@ -870,7 +870,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e44473e74bf92e8ad212bbdde5bfb88b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22530eedc11ebe27fdf14ab275b9ea28')" ] } } \ No newline at end of file diff --git a/app/src/main/java/org/wikipedia/Constants.kt b/app/src/main/java/org/wikipedia/Constants.kt index 1d4458b255a..71adfe12a01 100644 --- a/app/src/main/java/org/wikipedia/Constants.kt +++ b/app/src/main/java/org/wikipedia/Constants.kt @@ -113,7 +113,7 @@ object Constants { ON_THIS_DAY_GAME_ACTIVITY("onThisDayGame"), ACTIVITY_TAB("activityTab"), GAMES_HUB("gamesHub"), - INTEREST_SELECTION("interestSelection"), + FEED_INTEREST_SELECTION("feedInterestSelection"), } enum class ImageEditType(name: String) { diff --git a/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt index 20a4a12260a..849f9a22f1b 100644 --- a/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt +++ b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt @@ -26,6 +26,7 @@ import org.wikipedia.compose.theme.WikipediaTheme @Composable fun SearchBarCard( modifier: Modifier = Modifier, + text: String, onSearchClick: () -> Unit ) { Row( @@ -49,7 +50,7 @@ fun SearchBarCard( ) Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp), - text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), + text = text, style = MaterialTheme.typography.bodyLarge, color = WikipediaTheme.colors.primaryColor ) diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index ebdd64fc116..d26861b8012 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -21,10 +21,10 @@ import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao import org.wikipedia.offline.db.OfflineObject import org.wikipedia.offline.db.OfflineObjectDao -import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao -import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao -import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest -import org.wikipedia.onboarding.personalization.db.entity.TopicInterest +import org.wikipedia.onboarding.personalization.db.dao.InterestArticleDao +import org.wikipedia.onboarding.personalization.db.dao.InterestTopicDao +import org.wikipedia.onboarding.personalization.db.entity.InterestArticle +import org.wikipedia.onboarding.personalization.db.entity.InterestTopic import org.wikipedia.pageimages.db.PageImage import org.wikipedia.pageimages.db.PageImageDao import org.wikipedia.readinglist.database.ReadingList @@ -60,8 +60,8 @@ const val DATABASE_VERSION = 33 Category::class, DailyGameHistory::class, RecommendedPage::class, - TopicInterest::class, - ArticleInterest::class + InterestTopic::class, + InterestArticle::class ], version = DATABASE_VERSION ) @@ -87,8 +87,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun dailyGameHistoryDao(): DailyGameHistoryDao abstract fun recommendedPageDao(): RecommendedPageDao - abstract fun topicInterestDao(): TopicInterestDao - abstract fun articleInterestDao(): ArticleInterestDao + abstract fun topicInterestDao(): InterestTopicDao + abstract fun articleInterestDao(): InterestArticleDao companion object { val MIGRATION_19_20 = object : Migration(19, 20) { @@ -365,14 +365,14 @@ abstract class AppDatabase : RoomDatabase() { } val MIGRATION_32_33 = object : Migration(32, 33) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS TopicInterest (" + + db.execSQL("CREATE TABLE IF NOT EXISTS InterestTopic (" + "topicId TEXT NOT NULL," + "lang TEXT NOT NULL," + "topicLabel TEXT NOT NULL," + "queryTopicId TEXT NOT NULL," + "PRIMARY KEY (topicId, lang)" + ")") - db.execSQL("CREATE TABLE IF NOT EXISTS ArticleInterest (" + + db.execSQL("CREATE TABLE IF NOT EXISTS InterestArticle (" + "apiTitle TEXT NOT NULL," + "lang TEXT NOT NULL," + "namespace INTEGER NOT NULL," + diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt index fd4dac0daf2..49283c3dce2 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt @@ -3,6 +3,7 @@ package org.wikipedia.onboarding.personalization import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.rememberInfiniteTransition 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 @@ -95,7 +96,8 @@ fun InterestOnboardingScreen( content = { item(span = StaggeredGridItemSpan.FullLine) { SearchBarCard( - onSearchClick = onSearchClick + onSearchClick = onSearchClick, + text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint) ) } item(span = StaggeredGridItemSpan.FullLine) { @@ -184,7 +186,8 @@ fun InterestOnboardingScreen( SelectionBottomBar( modifier = Modifier .align(Alignment.BottomStart) - .background(WikipediaTheme.colors.paperColor), + .background(WikipediaTheme.colors.paperColor) + .clickable(enabled = false, onClick = {}), selectedItemsCount = totalSelectedCount, onDeselectAllClick = onDeselectAllClick ) diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt index f056f760e51..dad87995c7f 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationActivity.kt @@ -39,7 +39,7 @@ class PersonalizationActivity : BaseActivity() { ), onSkipClick = { finish() }, onSearchClick = { - val intent = SearchActivity.newIntent(this, Constants.InvokeSource.INTEREST_SELECTION, null, returnLink = true) + val intent = SearchActivity.newIntent(this, Constants.InvokeSource.FEED_INTEREST_SELECTION, null, returnLink = true) searchLauncher.launch(intent) }, showError = { message -> diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt index cbbe273515d..02d1d4d06b4 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt @@ -4,10 +4,10 @@ import androidx.room.Transaction import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.history.db.HistoryEntryWithImageDao -import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao -import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao -import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest -import org.wikipedia.onboarding.personalization.db.entity.TopicInterest +import org.wikipedia.onboarding.personalization.db.dao.InterestArticleDao +import org.wikipedia.onboarding.personalization.db.dao.InterestTopicDao +import org.wikipedia.onboarding.personalization.db.entity.InterestArticle +import org.wikipedia.onboarding.personalization.db.entity.InterestTopic import org.wikipedia.onboarding.personalization.topics.OnboardingTopics import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle @@ -16,8 +16,8 @@ import org.wikipedia.readinglist.db.ReadingListPageDao import org.wikipedia.util.StringUtil class PersonalizationRepository( - private val topicInterestDao: TopicInterestDao, - private val articleInterestDao: ArticleInterestDao, + private val interestTopicDao: InterestTopicDao, + private val interestArticleDao: InterestArticleDao, private val historyEntryWithImageDao: HistoryEntryWithImageDao, private val readingListPageDao: ReadingListPageDao, val wikiSite: WikiSite @@ -103,13 +103,13 @@ class PersonalizationRepository( } suspend fun getPersistedTopics(lang: String): List { - return topicInterestDao.getAll(lang).mapNotNull { entity -> + return interestTopicDao.getAll(lang).mapNotNull { entity -> OnboardingTopics.all.find { it.topicId == entity.topicId } } } suspend fun getPersistedArticles(lang: String): List { - return articleInterestDao.getAll(lang).map { entity -> + return interestArticleDao.getAll(lang).map { entity -> PageTitle( text = entity.apiTitle, wiki = wikiSite, @@ -121,18 +121,19 @@ class PersonalizationRepository( } suspend fun saveTopic(topic: OnboardingTopic, lang: String) { - topicInterestDao.insert( - topicInterest = TopicInterest( + interestTopicDao.insert( + interestTopic = InterestTopic( topicId = topic.topicId, topicLabel = topic.displayTitle, queryTopicId = topic.queryTopicId, lang = lang - )) + ) + ) } suspend fun deleteTopic(topic: OnboardingTopic, lang: String) { - topicInterestDao.delete( - topicInterest = TopicInterest( + interestTopicDao.delete( + interestTopic = InterestTopic( topicId = topic.topicId, topicLabel = topic.displayTitle, queryTopicId = topic.queryTopicId, @@ -142,8 +143,8 @@ class PersonalizationRepository( } suspend fun saveArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { - articleInterestDao.insert( - articleInterest = ArticleInterest( + interestArticleDao.insert( + interestArticle = InterestArticle( apiTitle = article.prefixedText, lang = lang, namespace = article.namespace(), @@ -157,8 +158,8 @@ class PersonalizationRepository( } suspend fun deleteArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { - articleInterestDao.delete( - articleInterest = ArticleInterest( + interestArticleDao.delete( + interestArticle = InterestArticle( apiTitle = article.prefixedText, lang = lang, namespace = article.namespace(), @@ -173,7 +174,7 @@ class PersonalizationRepository( @Transaction suspend fun deleteAllInterests() { - topicInterestDao.deleteAll() - articleInterestDao.deleteAll() + interestTopicDao.deleteAll() + interestArticleDao.deleteAll() } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index fa9bf820bc4..f3d49256ff5 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -292,8 +292,8 @@ class PersonalizationViewModel( initializer { PersonalizationViewModel( repository = PersonalizationRepository( - topicInterestDao = AppDatabase.instance.topicInterestDao(), - articleInterestDao = AppDatabase.instance.articleInterestDao(), + interestTopicDao = AppDatabase.instance.topicInterestDao(), + interestArticleDao = AppDatabase.instance.articleInterestDao(), historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), readingListPageDao = AppDatabase.instance.readingListPageDao(), wikiSite = WikipediaApp.instance.wikiSite diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt similarity index 53% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt rename to app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt index 556bc46bb67..e3b79b8c9a7 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/ArticleInterestDao.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt @@ -5,23 +5,23 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest +import org.wikipedia.onboarding.personalization.db.entity.InterestArticle import org.wikipedia.page.Namespace @Dao -interface ArticleInterestDao { +interface InterestArticleDao { @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(articleInterest: ArticleInterest) + suspend fun insert(interestArticle: InterestArticle) @Delete - suspend fun delete(articleInterest: ArticleInterest) + suspend fun delete(interestArticle: InterestArticle) - @Query("DELETE FROM ArticleInterest") + @Query("DELETE FROM InterestArticle") suspend fun deleteAll() - @Query("SELECT * FROM ArticleInterest WHERE lang = :lang") - suspend fun getAll(lang: String): List + @Query("SELECT * FROM InterestArticle WHERE lang = :lang") + suspend fun getAll(lang: String): List - @Query("UPDATE ArticleInterest SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") + @Query("UPDATE InterestArticle SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") suspend fun updateTopic(newTopicId: String, apiTitle: String, lang: String, namespace: Namespace) } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt new file mode 100644 index 00000000000..43292250b86 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt @@ -0,0 +1,23 @@ +package org.wikipedia.onboarding.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wikipedia.onboarding.personalization.db.entity.InterestTopic + +@Dao +interface InterestTopicDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(interestTopic: InterestTopic) + + @Delete + suspend fun delete(interestTopic: InterestTopic) + + @Query("DELETE FROM InterestTopic") + suspend fun deleteAll() + + @Query("SELECT * FROM InterestTopic WHERE lang = :lang") + suspend fun getAll(lang: String): List +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt deleted file mode 100644 index 2208a00e1e4..00000000000 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/TopicInterestDao.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.wikipedia.onboarding.personalization.db.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import org.wikipedia.onboarding.personalization.db.entity.TopicInterest - -@Dao -interface TopicInterestDao { - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(topicInterest: TopicInterest) - - @Delete - suspend fun delete(topicInterest: TopicInterest) - - @Query("DELETE FROM TopicInterest") - suspend fun deleteAll() - - @Query("SELECT * FROM TopicInterest WHERE lang = :lang") - suspend fun getAll(lang: String): List -} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt similarity index 94% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt rename to app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt index 07df9a72101..097787c83ea 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/ArticleInterest.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt @@ -9,7 +9,7 @@ import org.wikipedia.page.Namespace primaryKeys = ["apiTitle", "lang", "namespace"], foreignKeys = [ ForeignKey( - entity = TopicInterest::class, + entity = InterestTopic::class, parentColumns = ["topicId", "lang"], // primary key in the parent entity childColumns = ["topicId", "topicLang"], // foreign key in this entity which references the primary key in parent entity onDelete = ForeignKey.SET_NULL, // when a topic is deleted, the foreign key in this entity will be set to null to not delete the article interest but just disassociate it from the deleted topic @@ -17,7 +17,7 @@ import org.wikipedia.page.Namespace ], indices = [Index(value = ["topicId", "topicLang"])] // index for the foreign key columns to improve query performance especially for cascade operations ) -data class ArticleInterest( +data class InterestArticle( val apiTitle: String, val lang: String, val namespace: Namespace, diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt similarity index 90% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt rename to app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt index 394b6489837..44fb371b456 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/TopicInterest.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt @@ -5,7 +5,7 @@ import androidx.room.Entity @Entity( primaryKeys = ["topicId", "lang"] ) -data class TopicInterest( +data class InterestTopic( val topicId: String, val lang: String, val topicLabel: String, diff --git a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt index 8159cc38c81..ee6b9208941 100644 --- a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt @@ -350,7 +350,10 @@ fun RecommendedReadingListInterestsContent( ) } item(span = StaggeredGridItemSpan.FullLine) { - SearchBarCard(onSearchClick = onSearchClick) + SearchBarCard( + text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), + onSearchClick = onSearchClick + ) } items(items) { item -> ArticleCard( diff --git a/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt b/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt similarity index 60% rename from app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt rename to app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt index c27b262f205..eab2a72c39f 100644 --- a/app/src/test/java/org/wikipedia/database/ArticleInterestDaoTest.kt +++ b/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt @@ -10,17 +10,17 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment -import org.wikipedia.onboarding.personalization.db.dao.ArticleInterestDao -import org.wikipedia.onboarding.personalization.db.dao.TopicInterestDao -import org.wikipedia.onboarding.personalization.db.entity.ArticleInterest -import org.wikipedia.onboarding.personalization.db.entity.TopicInterest +import org.wikipedia.onboarding.personalization.db.dao.InterestArticleDao +import org.wikipedia.onboarding.personalization.db.dao.InterestTopicDao +import org.wikipedia.onboarding.personalization.db.entity.InterestArticle +import org.wikipedia.onboarding.personalization.db.entity.InterestTopic import org.wikipedia.page.Namespace @RunWith(RobolectricTestRunner::class) -class ArticleInterestDaoTest { +class InterestArticleDaoTest { private lateinit var db: AppDatabase - private lateinit var articleInterestDao: ArticleInterestDao - private lateinit var topicInterestDao: TopicInterestDao + private lateinit var interestArticleDao: InterestArticleDao + private lateinit var interestTopicDao: InterestTopicDao @Before fun setup() { @@ -28,47 +28,47 @@ class ArticleInterestDaoTest { db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() - articleInterestDao = db.articleInterestDao() - topicInterestDao = db.topicInterestDao() + interestArticleDao = db.articleInterestDao() + interestTopicDao = db.topicInterestDao() } @Test fun insert_and_getAll_byLang() = runBlocking { - val article = ArticleInterest(apiTitle = "Dog", lang = "en", namespace = Namespace.MAIN, displayTitle = "Dog", description = "Animal", thumbUrl = "") - articleInterestDao.insert(article) + val article = InterestArticle(apiTitle = "Dog", lang = "en", namespace = Namespace.MAIN, displayTitle = "Dog", description = "Animal", thumbUrl = "") + interestArticleDao.insert(article) - val results = articleInterestDao.getAll("en") + val results = interestArticleDao.getAll("en") assert(results.size == 1) assert(results[0].apiTitle == "Dog") } @Test fun articleInterest_foreignKey_setNull_onTopicDelete() = runBlocking { - val topic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") - topicInterestDao.insert(topic) + val topic = InterestTopic(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + interestTopicDao.insert(topic) - val cookingTopic = TopicInterest(topicId = "Cooking", lang = "en", topicLabel = "Cooking", queryTopicId = "Cooking") - topicInterestDao.insert(cookingTopic) + val cookingTopic = InterestTopic(topicId = "Cooking", lang = "en", topicLabel = "Cooking", queryTopicId = "Cooking") + interestTopicDao.insert(cookingTopic) - val article1 = ArticleInterest(apiTitle = "Football", lang = "en", namespace = Namespace.MAIN, displayTitle = "Football", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) - articleInterestDao.insert(article1) + val article1 = InterestArticle(apiTitle = "Football", lang = "en", namespace = Namespace.MAIN, displayTitle = "Football", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + interestArticleDao.insert(article1) - val article2 = ArticleInterest(apiTitle = "Basketball", lang = "en", namespace = Namespace.MAIN, displayTitle = "Basketball", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) - articleInterestDao.insert(article2) + val article2 = InterestArticle(apiTitle = "Basketball", lang = "en", namespace = Namespace.MAIN, displayTitle = "Basketball", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + interestArticleDao.insert(article2) - val article3 = ArticleInterest(apiTitle = "Tennis", lang = "en", namespace = Namespace.MAIN, displayTitle = "Tennis", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) - articleInterestDao.insert(article3) + val article3 = InterestArticle(apiTitle = "Tennis", lang = "en", namespace = Namespace.MAIN, displayTitle = "Tennis", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + interestArticleDao.insert(article3) - val article4 = ArticleInterest(apiTitle = "Cricket", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cricket", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) - articleInterestDao.insert(article4) + val article4 = InterestArticle(apiTitle = "Cricket", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cricket", description = "Sport", thumbUrl = "", topicId = topic.topicId, topicLang = topic.lang) + interestArticleDao.insert(article4) - val article5 = ArticleInterest(apiTitle = "Cooking", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cooking", description = "Hobby", thumbUrl = "", topicId = cookingTopic.topicId, topicLang = cookingTopic.lang) - articleInterestDao.insert(article5) + val article5 = InterestArticle(apiTitle = "Cooking", lang = "en", namespace = Namespace.MAIN, displayTitle = "Cooking", description = "Hobby", thumbUrl = "", topicId = cookingTopic.topicId, topicLang = cookingTopic.lang) + interestArticleDao.insert(article5) // Delete the topic and check that the foreign key in articles is set to null - topicInterestDao.delete(topic) + interestTopicDao.delete(topic) - val results = articleInterestDao.getAll("en") + val results = interestArticleDao.getAll("en") assert(results.size == 5) val cookingResult = results.first { it.topicId == cookingTopic.topicId } @@ -84,36 +84,36 @@ class ArticleInterestDaoTest { @Test fun updateTopic_reassignsArticleToNewTopic() = runBlocking { - val sportsTopic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") - topicInterestDao.insert(sportsTopic) + val sportsTopic = InterestTopic(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + interestTopicDao.insert(sportsTopic) - val technologyTopic = TopicInterest(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") - topicInterestDao.insert(technologyTopic) + val technologyTopic = InterestTopic(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + interestTopicDao.insert(technologyTopic) - val article = ArticleInterest(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = "sports", topicLang = "en") - articleInterestDao.insert(article) + val article = InterestArticle(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = "sports", topicLang = "en") + interestArticleDao.insert(article) - articleInterestDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = article.apiTitle, lang = article.lang, namespace = article.namespace) - val results = articleInterestDao.getAll("en").first() + interestArticleDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = article.apiTitle, lang = article.lang, namespace = article.namespace) + val results = interestArticleDao.getAll("en").first() assertTrue(results.topicId == technologyTopic.topicId) } @Test fun updateTopic_doesNotAffectOtherArticles() = runBlocking { - val sportsTopic = TopicInterest(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") - topicInterestDao.insert(sportsTopic) + val sportsTopic = InterestTopic(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + interestTopicDao.insert(sportsTopic) - val technologyTopic = TopicInterest(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") - topicInterestDao.insert(technologyTopic) + val technologyTopic = InterestTopic(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + interestTopicDao.insert(technologyTopic) - val article1 = ArticleInterest(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) - val article2 = ArticleInterest(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) - articleInterestDao.insert(article1) - articleInterestDao.insert(article2) + val article1 = InterestArticle(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) + val article2 = InterestArticle(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) + interestArticleDao.insert(article1) + interestArticleDao.insert(article2) - articleInterestDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN) + interestArticleDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN) - val results = articleInterestDao.getAll("en") + val results = interestArticleDao.getAll("en") assert(results.size == 2) val johnDoeArticle = results.firstOrNull { it.apiTitle == "John Doe" } val janeDoeArticle = results.firstOrNull { it.apiTitle == "Jane Doe" } From 2f008fde0a50f1ba2ec40c325e2fb1e595542aba Mon Sep 17 00:00:00 2001 From: williamrai Date: Tue, 14 Apr 2026 17:00:01 -0400 Subject: [PATCH 30/39] - adds feed preference state, structure and base UI - adds string resource - adds autoSize parameter to HtmlText --- .../wikipedia/compose/components/HtmlText.kt | 7 +- .../personalization/FeedPreferenceScreen.kt | 347 ++++++++++++++++++ .../personalization/PersonalizationScreen.kt | 11 +- .../personalization/PersonalizationState.kt | 27 ++ .../PersonalizationViewModel.kt | 34 +- app/src/main/res/values-qq/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 7 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt 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/onboarding/personalization/FeedPreferenceScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt new file mode 100644 index 00000000000..3df66b0f33e --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt @@ -0,0 +1,347 @@ +package org.wikipedia.onboarding.personalization + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.foundation.text.TextAutoSize +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.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 androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.components.WikiCard +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 +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.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), + ) { + when (communityContentState) { + is FeedContentState.Error -> {} + FeedContentState.Loading -> {} + is FeedContentState.Success -> { + item { + Content( + isSelected = selectedType == FeedPreferenceType.COMMUNITY, + feedPreferenceType = FeedPreferenceType.COMMUNITY, + onSelected = { onTypeSelected(FeedPreferenceType.COMMUNITY) }, + feedPreferenceContent = communityContentState.content + ) + } + } + } + + when (personalizedContentState) { + is FeedContentState.Error -> {} + FeedContentState.Loading -> {} + is FeedContentState.Success -> { + item { + Content( + isSelected = selectedType == FeedPreferenceType.PERSONALIZED, + feedPreferenceType = FeedPreferenceType.PERSONALIZED, + onSelected = { onTypeSelected(FeedPreferenceType.PERSONALIZED) }, + feedPreferenceContent = personalizedContentState.content + ) + } + } + } + } + } +} + +@Composable +fun Content( + modifier: Modifier = Modifier, + isSelected: Boolean, + feedPreferenceType: FeedPreferenceType, + onSelected: (FeedPreferenceType) -> Unit = {}, + feedPreferenceContent: List +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { onSelected(feedPreferenceType) }, + colors = RadioButtonDefaults.colors( + selectedColor = WikipediaTheme.colors.primaryColor, + unselectedColor = WikipediaTheme.colors.primaryColor + ) + ) + Text( + text = stringResource(feedPreferenceType.titleRes), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.primaryColor + ) + } + + LazyRow( + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(feedPreferenceContent) { content -> + FeedPreferenceArticleCard( + content = FeedPreferenceContent( + title = content.title, + description = content.description, + imageUrl = content.imageUrl, + tag = content.tag + ) + ) + } + } + } +} + +@Composable +fun FeedPreferenceArticleCard( + modifier: Modifier = Modifier, + 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) + ) { + if (!content.imageUrl.isNullOrEmpty()) { + 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) + ) + } + ContentTag( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + text = content.tag + ) + } + Column( + modifier = Modifier + .padding(16.dp) + .weight(1f) + ) { + 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 = 3, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = 10.sp, + maxFontSize = 14.sp, + ) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +fun ContentTag( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier + .background(WikipediaTheme.colors.progressiveColor, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 4.dp), + 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 = { } + ) + } +} + +@Preview(showBackground = true, fontScale = 1.5f, device = Devices.PIXEL_9) +@Composable +private fun FeedPreferenceScreenScaledTextPreview() { + 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 = { } + ) + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 4c093e38b81..56d80b34826 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -45,6 +45,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) { @@ -106,7 +107,15 @@ fun PersonalizationScreen( ) } PersonalizationPage.FEED_PREFERENCE -> { - // TODO: implement feed preference screen + FeedPreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor), + selectedType = feedPreferenceUiState.value.selectedType, + communityContentState = feedPreferenceUiState.value.communityState, + personalizedContentState = feedPreferenceUiState.value.personalizedState, + onTypeSelected = { } + ) } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt index 5f85be0bc6d..7027c014e9f 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt @@ -1,7 +1,9 @@ package org.wikipedia.onboarding.personalization +import org.wikipedia.R import org.wikipedia.page.PageTitle +// Interest Selection screen state data class OnboardingTopic( val topicId: String, val msgKey: String, @@ -27,3 +29,28 @@ sealed interface ArticlesState { data class Success(val articles: List, val selectedArticles: Set) : ArticlesState data class Error(val message: Throwable) : ArticlesState } + +// Feed Preference screen state +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 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/onboarding/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt index f3d49256ff5..53a50f19457 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationViewModel.kt @@ -32,8 +32,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( @@ -60,8 +67,21 @@ 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) + else -> FeedContentState.Success(personalizedContent) + } + ) + } } class PersonalizationViewModel( @@ -81,6 +101,14 @@ 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 -> loadScreen() diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index ba5c1476128..622339c84f0 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2232,4 +2232,7 @@ 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. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e25c6ba64ca..2e265736325 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2485,4 +2485,7 @@ 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 \ No newline at end of file From 8291eccb492b4f54baa24c18e3f11d0df52cc585 Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 15 Apr 2026 11:39:19 -0400 Subject: [PATCH 31/39] - feed screen architecture refinement --- .../onboarding/InitialOnboardingActivity.kt | 2 +- .../personalization/FeedPreferenceScreen.kt | 195 +++++++++++++----- .../personalization/PersonalizationScreen.kt | 6 +- 3 files changed, 143 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index 5e4025c14f7..81cef840b2d 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/onboarding/personalization/FeedPreferenceScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt index 3df66b0f33e..5e944f7f13a 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/FeedPreferenceScreen.kt @@ -1,7 +1,9 @@ package org.wikipedia.onboarding.personalization +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 @@ -26,6 +28,7 @@ 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 @@ -41,6 +44,9 @@ 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 @@ -52,11 +58,12 @@ fun FeedPreferenceScreen( selectedType: FeedPreferenceType, communityContentState: FeedContentState, personalizedContentState: FeedContentState, - onTypeSelected: (FeedPreferenceType) -> Unit + onTypeSelected: (FeedPreferenceType) -> Unit, + onRetryClick: () -> Unit ) { Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( modifier = Modifier.padding(horizontal = 16.dp), @@ -68,58 +75,49 @@ fun FeedPreferenceScreen( ) LazyColumn( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - when (communityContentState) { - is FeedContentState.Error -> {} - FeedContentState.Loading -> {} - is FeedContentState.Success -> { - item { - Content( - isSelected = selectedType == FeedPreferenceType.COMMUNITY, - feedPreferenceType = FeedPreferenceType.COMMUNITY, - onSelected = { onTypeSelected(FeedPreferenceType.COMMUNITY) }, - feedPreferenceContent = communityContentState.content - ) - } - } + item { + FeedPreferenceSection( + state = communityContentState, + isSelected = selectedType == FeedPreferenceType.COMMUNITY, + feedPreferenceType = FeedPreferenceType.COMMUNITY, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) } - - when (personalizedContentState) { - is FeedContentState.Error -> {} - FeedContentState.Loading -> {} - is FeedContentState.Success -> { - item { - Content( - isSelected = selectedType == FeedPreferenceType.PERSONALIZED, - feedPreferenceType = FeedPreferenceType.PERSONALIZED, - onSelected = { onTypeSelected(FeedPreferenceType.PERSONALIZED) }, - feedPreferenceContent = personalizedContentState.content - ) - } - } + item { + FeedPreferenceSection( + state = personalizedContentState, + isSelected = selectedType == FeedPreferenceType.PERSONALIZED, + feedPreferenceType = FeedPreferenceType.PERSONALIZED, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) } } } } @Composable -fun Content( - modifier: Modifier = Modifier, +fun FeedPreferenceSection( + state: FeedContentState, isSelected: Boolean, feedPreferenceType: FeedPreferenceType, - onSelected: (FeedPreferenceType) -> Unit = {}, - feedPreferenceContent: List + onRetryClick: () -> Unit, + onSelected: (FeedPreferenceType) -> Unit ) { + val transition = rememberInfiniteTransition(label = "feedPreferenceShimmerTransition") + Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp), + .padding(horizontal = 8.dp) + .clickable(onClick = { onSelected(feedPreferenceType) }), verticalAlignment = Alignment.CenterVertically ) { RadioButton( @@ -140,18 +138,47 @@ fun Content( } LazyRow( + modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - items(feedPreferenceContent) { content -> - FeedPreferenceArticleCard( - content = FeedPreferenceContent( - title = content.title, - description = content.description, - imageUrl = content.imageUrl, - tag = content.tag - ) - ) + when (state) { + is FeedContentState.Error -> { + item { + Box( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + caught = state.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = onRetryClick + ) + ) + } + } + } + + FeedContentState.Loading -> { + items(3) { + Box( + modifier = Modifier + .width(185.dp) + .height(230.dp) + .clip(RoundedCornerShape(size = 12.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + is FeedContentState.Success -> { + items(state.content) { content -> + FeedPreferenceArticleCard( + content = content, + feedPreferenceType = feedPreferenceType + ) + } + } } } } @@ -160,6 +187,7 @@ fun Content( @Composable fun FeedPreferenceArticleCard( modifier: Modifier = Modifier, + feedPreferenceType: FeedPreferenceType, content: FeedPreferenceContent ) { WikiCard( @@ -191,10 +219,17 @@ fun FeedPreferenceArticleCard( .height(108.dp) ) } - ContentTag( + ArticleCardTag( modifier = Modifier .align(Alignment.BottomStart) - .padding(8.dp), + .padding(8.dp) + .background( + when (feedPreferenceType) { + FeedPreferenceType.COMMUNITY -> WikipediaTheme.colors.progressiveColor + FeedPreferenceType.PERSONALIZED -> WikipediaTheme.colors.progressiveColor // TODO: Update color once confirmed with design + }, shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp), text = content.tag ) } @@ -232,14 +267,12 @@ fun FeedPreferenceArticleCard( } @Composable -fun ContentTag( +fun ArticleCardTag( text: String, modifier: Modifier = Modifier ) { Text( - modifier = modifier - .background(WikipediaTheme.colors.progressiveColor, shape = RoundedCornerShape(8.dp)) - .padding(horizontal = 12.dp, vertical = 4.dp), + modifier = modifier, text = text, style = MaterialTheme.typography.bodySmall.copy( fontWeight = FontWeight.Medium @@ -292,7 +325,8 @@ private fun FeedPreferenceScreenPreview() { ) ) ), - onTypeSelected = { } + onTypeSelected = {}, + onRetryClick = {} ) } } @@ -301,7 +335,7 @@ private fun FeedPreferenceScreenPreview() { @Composable private fun FeedPreferenceScreenScaledTextPreview() { BaseTheme( - currentTheme = Theme.LIGHT + currentTheme = Theme.DARK ) { FeedPreferenceScreen( modifier = Modifier @@ -334,14 +368,61 @@ private fun FeedPreferenceScreenScaledTextPreview() { personalizedContentState = FeedContentState.Success( content = listOf( FeedPreferenceContent( - title = "Personalized Content", - description = "See content that’s personalized for you based on your reading history and interests.", + 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 = "Personalized" + 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 = { } + 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 = {} ) } } diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt index 56d80b34826..bb4d47c3fce 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationScreen.kt @@ -110,11 +110,13 @@ fun PersonalizationScreen( FeedPreferenceScreen( modifier = Modifier .fillMaxSize() - .background(WikipediaTheme.colors.paperColor), + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), selectedType = feedPreferenceUiState.value.selectedType, communityContentState = feedPreferenceUiState.value.communityState, personalizedContentState = feedPreferenceUiState.value.personalizedState, - onTypeSelected = { } + onTypeSelected = {}, + onRetryClick = {} ) } } From 60d8d1ce152ca5ae53df678f94580a16f406549f Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 15 Apr 2026 13:27:41 -0400 Subject: [PATCH 32/39] - moves personalization to feed package, separate interest into its own package --- app/src/main/AndroidManifest.xml | 2 +- .../org/wikipedia/database/AppDatabase.kt | 8 ++-- .../ExploreFeedUpdatePromptActivity.kt | 2 +- .../OnboardingCuriosityScreen.kt | 2 +- .../PersonalizationActivity.kt | 2 +- .../personalization/PersonalizationScreen.kt | 3 +- .../PersonalizationViewModel.kt | 19 ++++---- .../db/dao/InterestArticleDao.kt | 4 +- .../db/dao/InterestTopicDao.kt | 4 +- .../db/entity/InterestArticle.kt | 2 +- .../db/entity/InterestTopic.kt | 2 +- .../interest/InterestSelectionRepository.kt} | 45 +++++++++---------- .../interest/InterestSelectionScreen.kt} | 4 +- .../interest/InterestSelectionState.kt} | 2 +- .../topics/OnboardingTopics.kt | 4 +- .../onboarding/InitialOnboardingActivity.kt | 2 +- .../database/InterestArticleDaoTest.kt | 8 ++-- 17 files changed, 58 insertions(+), 57 deletions(-) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/OnboardingCuriosityScreen.kt (98%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/PersonalizationActivity.kt (97%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/PersonalizationScreen.kt (98%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/PersonalizationViewModel.kt (94%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/db/dao/InterestArticleDao.kt (86%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/db/dao/InterestTopicDao.kt (81%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/db/entity/InterestArticle.kt (95%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/db/entity/InterestTopic.kt (78%) rename app/src/main/java/org/wikipedia/{onboarding/personalization/PersonalizationRepository.kt => feed/personalization/interest/InterestSelectionRepository.kt} (79%) rename app/src/main/java/org/wikipedia/{onboarding/personalization/InterestOnboardingScreen.kt => feed/personalization/interest/InterestSelectionScreen.kt} (99%) rename app/src/main/java/org/wikipedia/{onboarding/personalization/PersonalizationState.kt => feed/personalization/interest/InterestSelectionState.kt} (94%) rename app/src/main/java/org/wikipedia/{onboarding => feed}/personalization/topics/OnboardingTopics.kt (98%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41478f0488f..4a151e75e9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -422,7 +422,7 @@ - + loadScreen() + PersonalizationPage.INTERESTS -> loadInterestSelectionScreen() else -> {} } } - private fun loadScreen() { + private fun loadInterestSelectionScreen() { viewModelScope.launch( CoroutineExceptionHandler { _, throwable -> L.e(throwable) }) { @@ -153,8 +157,7 @@ class PersonalizationViewModel( }) { state.update { it.copy(articlesLoading = true, articlesError = null) } - val selectedItems = Prefs.recommendedReadingListInterests - val articles = repository.loadInitialArticles(selectedItems) + val articles = repository.loadInitialArticles() state.update { current -> val newArticles = (current.selectedArticles + articles).distinct() current.copy( @@ -291,7 +294,7 @@ class PersonalizationViewModel( val Factory = viewModelFactory { initializer { PersonalizationViewModel( - repository = PersonalizationRepository( + repository = InterestSelectionRepository( interestTopicDao = AppDatabase.instance.topicInterestDao(), interestArticleDao = AppDatabase.instance.articleInterestDao(), historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt similarity index 86% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt rename to app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt index e3b79b8c9a7..2350e548c61 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestArticleDao.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt @@ -1,11 +1,11 @@ -package org.wikipedia.onboarding.personalization.db.dao +package org.wikipedia.feed.personalization.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.wikipedia.onboarding.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestArticle import org.wikipedia.page.Namespace @Dao diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt similarity index 81% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt rename to app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt index 43292250b86..9afd7c1fb02 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/dao/InterestTopicDao.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt @@ -1,11 +1,11 @@ -package org.wikipedia.onboarding.personalization.db.dao +package org.wikipedia.feed.personalization.db.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.wikipedia.onboarding.personalization.db.entity.InterestTopic +import org.wikipedia.feed.personalization.db.entity.InterestTopic @Dao interface InterestTopicDao { diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt similarity index 95% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt rename to app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt index 097787c83ea..8b398c1ba84 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestArticle.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt @@ -1,4 +1,4 @@ -package org.wikipedia.onboarding.personalization.db.entity +package org.wikipedia.feed.personalization.db.entity import androidx.room.Entity import androidx.room.ForeignKey diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt similarity index 78% rename from app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt rename to app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt index 44fb371b456..70487353a9f 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/db/entity/InterestTopic.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt @@ -1,4 +1,4 @@ -package org.wikipedia.onboarding.personalization.db.entity +package org.wikipedia.feed.personalization.db.entity import androidx.room.Entity diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt similarity index 79% rename from app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt rename to app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt index 02d1d4d06b4..ee5b3bc532e 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt @@ -1,21 +1,21 @@ -package org.wikipedia.onboarding.personalization +package org.wikipedia.feed.personalization.interest import androidx.room.Transaction import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +import org.wikipedia.feed.personalization.db.dao.InterestTopicDao +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestTopic +import org.wikipedia.feed.personalization.topics.OnboardingTopics import org.wikipedia.history.db.HistoryEntryWithImageDao -import org.wikipedia.onboarding.personalization.db.dao.InterestArticleDao -import org.wikipedia.onboarding.personalization.db.dao.InterestTopicDao -import org.wikipedia.onboarding.personalization.db.entity.InterestArticle -import org.wikipedia.onboarding.personalization.db.entity.InterestTopic -import org.wikipedia.onboarding.personalization.topics.OnboardingTopics import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.readinglist.db.ReadingListPageDao import org.wikipedia.util.StringUtil -class PersonalizationRepository( +class InterestSelectionRepository( private val interestTopicDao: InterestTopicDao, private val interestArticleDao: InterestArticleDao, private val historyEntryWithImageDao: HistoryEntryWithImageDao, @@ -51,26 +51,23 @@ class PersonalizationRepository( return pageList } - suspend fun loadInitialArticles(selectedItems: List): List { + suspend fun loadInitialArticles(): List { val maxItems = 20 val results = mutableListOf() - results.addAll(selectedItems) - - if (results.size < maxItems) { - // get most recent history entries - val historyTitles = historyEntryWithImageDao.findEntryForReadMore(maxItems, 0) - .map { it.title } - // and a random sampling of reading list pages - val readingListTitles = readingListPageDao.getPagesByRandom(maxItems) - .map { ReadingListPage.toPageTitle(it) } - // take the two lists and interleave them - for (i in 0 until maxItems) { - if (i < historyTitles.size && !results.contains(historyTitles[i])) results.add(historyTitles[i]) - if (i < readingListTitles.size && !results.contains(readingListTitles[i])) results.add(readingListTitles[i]) - } - // remove non-main namespace articles, or Main page - results.removeAll { it.isMainPage || it.namespace() != Namespace.MAIN } + + // get most recent history entries + val historyTitles = historyEntryWithImageDao.findEntryForReadMore(maxItems, 0) + .map { it.title } + // and a random sampling of reading list pages + val readingListTitles = readingListPageDao.getPagesByRandom(maxItems) + .map { ReadingListPage.toPageTitle(it) } + // take the two lists and interleave them + for (i in 0 until maxItems) { + if (i < historyTitles.size && !results.contains(historyTitles[i])) results.add(historyTitles[i]) + if (i < readingListTitles.size && !results.contains(readingListTitles[i])) results.add(readingListTitles[i]) } + // remove non-main namespace articles, or Main page + results.removeAll { it.isMainPage || it.namespace() != Namespace.MAIN } // If there are still VERY few items, include a few random articles. val maxRandomItems = 6 diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt similarity index 99% rename from app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt rename to app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt index 49283c3dce2..b28adb5ab93 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/InterestOnboardingScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt @@ -1,4 +1,4 @@ -package org.wikipedia.onboarding.personalization +package org.wikipedia.feed.personalization.interest import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.rememberInfiniteTransition @@ -53,7 +53,7 @@ import org.wikipedia.compose.extensions.shimmerEffect import org.wikipedia.compose.theme.BaseTheme import org.wikipedia.compose.theme.WikipediaTheme import org.wikipedia.dataclient.WikiSite -import org.wikipedia.onboarding.personalization.topics.OnboardingTopics +import org.wikipedia.feed.personalization.topics.OnboardingTopics import org.wikipedia.page.PageTitle import org.wikipedia.theme.Theme diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt similarity index 94% rename from app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt rename to app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt index 5f85be0bc6d..eca1e9d36b9 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/PersonalizationState.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt @@ -1,4 +1,4 @@ -package org.wikipedia.onboarding.personalization +package org.wikipedia.feed.personalization.interest import org.wikipedia.page.PageTitle diff --git a/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt similarity index 98% rename from app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt rename to app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt index b759f2a4399..fd754bec164 100644 --- a/app/src/main/java/org/wikipedia/onboarding/personalization/topics/OnboardingTopics.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt @@ -1,6 +1,6 @@ -package org.wikipedia.onboarding.personalization.topics +package org.wikipedia.feed.personalization.topics -import org.wikipedia.onboarding.personalization.OnboardingTopic +import org.wikipedia.feed.personalization.interest.OnboardingTopic // the values defined here are from https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/WikimediaMessages/+/refs/heads/master/includes/ArticleTopicFiltersRegistry.php object OnboardingTopics { diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index 5e4025c14f7..bf426833bca 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -18,8 +18,8 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.feed.personalization.PersonalizationActivity import org.wikipedia.language.AppLanguageState -import org.wikipedia.onboarding.personalization.PersonalizationActivity import org.wikipedia.settings.Prefs import org.wikipedia.settings.languages.WikipediaLanguagesActivity import org.wikipedia.theme.Theme diff --git a/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt b/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt index eab2a72c39f..9fbdc245064 100644 --- a/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt +++ b/app/src/test/java/org/wikipedia/database/InterestArticleDaoTest.kt @@ -10,10 +10,10 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment -import org.wikipedia.onboarding.personalization.db.dao.InterestArticleDao -import org.wikipedia.onboarding.personalization.db.dao.InterestTopicDao -import org.wikipedia.onboarding.personalization.db.entity.InterestArticle -import org.wikipedia.onboarding.personalization.db.entity.InterestTopic +import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +import org.wikipedia.feed.personalization.db.dao.InterestTopicDao +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestTopic import org.wikipedia.page.Namespace @RunWith(RobolectricTestRunner::class) From eb96ce716dca6454f57bc4631a7842b7a4467bb8 Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 15 Apr 2026 13:43:02 -0400 Subject: [PATCH 33/39] - moves feedPreference to its own package - adds FeedPreferenceRepository and FeedPreferenceState --- .../personalization/PersonalizationScreen.kt | 1 + .../PersonalizationViewModel.kt | 12 ++++++++- .../FeedPreferenceRepository.kt | 9 +++++++ .../FeedPreferenceScreen.kt | 2 +- .../feedpreference/FeedPreferenceState.kt | 27 +++++++++++++++++++ .../interest/InterestSelectionState.kt | 26 ------------------ 6 files changed, 49 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt rename app/src/main/java/org/wikipedia/feed/personalization/{ => feedpreference}/FeedPreferenceScreen.kt (99%) create mode 100644 app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt 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 9b7afd4c268..9aa96d8fd75 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 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 6bb36a19960..c10db540bc1 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 @@ -89,7 +94,8 @@ private data class PersonalizedViewModelState( } class PersonalizationViewModel( - private val repository: InterestSelectionRepository + private val repository: 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()) @@ -328,6 +334,10 @@ class PersonalizationViewModel( historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), readingListPageDao = AppDatabase.instance.readingListPageDao(), wikiSite = WikipediaApp.instance.wikiSite + ), + feedPreferenceRepository = FeedPreferenceRepository( + interestTopicDao = AppDatabase.instance.topicInterestDao(), + interestArticleDao = AppDatabase.instance.articleInterestDao(), ) ) } 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..778350fc959 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -0,0 +1,9 @@ +package org.wikipedia.feed.personalization.feedpreference + +import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +import org.wikipedia.feed.personalization.db.dao.InterestTopicDao + +class FeedPreferenceRepository( + private val interestTopicDao: InterestTopicDao, + private val interestArticleDao: InterestArticleDao +) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/FeedPreferenceScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt similarity index 99% rename from app/src/main/java/org/wikipedia/feed/personalization/FeedPreferenceScreen.kt rename to app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt index 5e944f7f13a..ec78c260ca9 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/FeedPreferenceScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt @@ -1,4 +1,4 @@ -package org.wikipedia.onboarding.personalization +package org.wikipedia.feed.personalization.feedpreference import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.BorderStroke 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..7de5659616d --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt @@ -0,0 +1,27 @@ +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 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/feed/personalization/interest/InterestSelectionState.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt index 6aae53f6893..ff89dacee9c 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt @@ -1,6 +1,5 @@ package org.wikipedia.feed.personalization.interest -import org.wikipedia.R import org.wikipedia.page.PageTitle // Interest Selection screen state @@ -29,28 +28,3 @@ sealed interface ArticlesState { data class Success(val articles: List, val selectedArticles: Set) : ArticlesState data class Error(val message: Throwable) : ArticlesState } - -// Feed Preference screen state -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 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 -) From f52b6054d600fb887c7962c5a6eee7bb4817d01d Mon Sep 17 00:00:00 2001 From: williamrai Date: Wed, 15 Apr 2026 13:47:25 -0400 Subject: [PATCH 34/39] - rename repository to interestSelectionRepository --- .../PersonalizationViewModel.kt | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) 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 58588a267ab..0af02a2b915 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -69,7 +69,7 @@ private data class PersonalizedViewModelState( } class PersonalizationViewModel( - private val repository: InterestSelectionRepository + private val interestSelectionRepository: InterestSelectionRepository ) : 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()) @@ -107,8 +107,8 @@ class PersonalizationViewModel( runCatching { state.update { it.copy(topicsLoading = true, topicsError = null) } - val langCode = repository.wikiSite.languageCode - val topics = repository.getTopics(langCode) + val langCode = interestSelectionRepository.wikiSite.languageCode + val topics = interestSelectionRepository.getTopics(langCode) state.update { it.copy(topics = topics, topicsLoading = false) } }.onFailure { throwable -> @@ -118,10 +118,10 @@ class PersonalizationViewModel( private suspend fun initialize() { runCatching { - val langCode = repository.wikiSite.languageCode + val langCode = interestSelectionRepository.wikiSite.languageCode // check db for persisted interest (topic and articles) data - val persistedTopics = repository.getPersistedTopics(langCode) - val persistedArticles = repository.getPersistedArticles(langCode) + val persistedTopics = interestSelectionRepository.getPersistedTopics(langCode) + val persistedArticles = interestSelectionRepository.getPersistedArticles(langCode) val hasPersistedData = persistedTopics.isNotEmpty() || persistedArticles.isNotEmpty() if (!hasPersistedData && state.value.articles.isEmpty()) { @@ -157,7 +157,7 @@ class PersonalizationViewModel( }) { state.update { it.copy(articlesLoading = true, articlesError = null) } - val articles = repository.loadInitialArticles() + val articles = interestSelectionRepository.loadInitialArticles() state.update { current -> val newArticles = (current.selectedArticles + articles).distinct() current.copy( @@ -177,7 +177,7 @@ class PersonalizationViewModel( }) { state.update { it.copy(articlesLoading = true, articlesError = null) } - val articles = repository.getArticlesByTopic(topic.queryTopicId) + val articles = interestSelectionRepository.getArticlesByTopic(topic.queryTopicId) state.update { current -> val newArticles = (current.selectedArticles.toList() + articles).distinct() current.copy(articles = newArticles, articlesLoading = false) @@ -187,7 +187,7 @@ class PersonalizationViewModel( // as we have a single state it becomes easier to update and control the state fun onTopicSelected(topic: OnboardingTopic) { - val lang = repository.wikiSite.languageCode + val lang = interestSelectionRepository.wikiSite.languageCode // When a category is selected, we want to reset the articles state and load articles for the selected category viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> @@ -203,9 +203,9 @@ class PersonalizationViewModel( } if (isSelected) { - repository.deleteTopic(topic, lang) + interestSelectionRepository.deleteTopic(topic, lang) } else { - repository.saveTopic(topic, lang) + interestSelectionRepository.saveTopic(topic, lang) } state.update { current -> @@ -226,7 +226,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.saveArticle(title, repository.wikiSite.languageCode, null) + interestSelectionRepository.saveArticle(title, interestSelectionRepository.wikiSite.languageCode, null) state.update { val newItems = listOf(title) + it.articles val newSelection = it.selectedArticles + title @@ -236,7 +236,7 @@ class PersonalizationViewModel( } fun toggleArticleSelection(title: PageTitle) { - val lang = repository.wikiSite.languageCode + val lang = interestSelectionRepository.wikiSite.languageCode viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> state.update { it.copy(articlesError = throwable) } @@ -246,9 +246,9 @@ class PersonalizationViewModel( val currentSelectedTopic = current.selectedTopics.lastOrNull() if (isSelected) { - repository.deleteArticle(title, lang, currentSelectedTopic) + interestSelectionRepository.deleteArticle(title, lang, currentSelectedTopic) } else { - repository.saveArticle(title, lang, currentSelectedTopic) + interestSelectionRepository.saveArticle(title, lang, currentSelectedTopic) } state.update { currentState -> @@ -268,7 +268,7 @@ class PersonalizationViewModel( state.update { it.copy(articlesError = throwable) } } ) { - repository.deleteAllInterests() + interestSelectionRepository.deleteAllInterests() state.update { it.copy( @@ -294,7 +294,7 @@ class PersonalizationViewModel( val Factory = viewModelFactory { initializer { PersonalizationViewModel( - repository = InterestSelectionRepository( + interestSelectionRepository = InterestSelectionRepository( interestTopicDao = AppDatabase.instance.topicInterestDao(), interestArticleDao = AppDatabase.instance.articleInterestDao(), historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), From e266cac0c096a35c6584e8d8bb602c69ab7bb2f5 Mon Sep 17 00:00:00 2001 From: williamrai Date: Thu, 16 Apr 2026 15:59:39 -0400 Subject: [PATCH 35/39] - adds DTO class ArticleWithTopic to query results from both entities using getArticlesWithTopic function - adds loadFeedPreferenceScreen - adds dev settings for selecting feed preference type - code/ui fixes --- .../java/org/wikipedia/feed/news/NewsItem.kt | 2 +- .../personalization/PersonalizationScreen.kt | 6 +- .../PersonalizationViewModel.kt | 46 +++++++++++- .../personalization/db/ArticleWithTopic.kt | 10 +++ .../db/dao/InterestArticleDao.kt | 16 +++++ .../FeedPreferenceRepository.kt | 72 ++++++++++++++++++- .../feedpreference/FeedPreferenceScreen.kt | 37 +++++----- .../feedpreference/FeedPreferenceState.kt | 2 +- .../main/java/org/wikipedia/settings/Prefs.kt | 7 ++ .../dev/DeveloperSettingsPreferenceLoader.kt | 18 +++++ app/src/main/res/values/preference_keys.xml | 1 + .../main/res/xml/developer_preferences.xml | 3 + 12 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt 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 9aa96d8fd75..0184165f555 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt @@ -103,7 +103,7 @@ fun PersonalizationScreen( viewModel.deselectAllArticles() }, retryLoading = { - viewModel.retryLoading() + viewModel.retryInterestsLoading() }, showError = showError ) @@ -117,8 +117,8 @@ fun PersonalizationScreen( selectedType = feedPreferenceUiState.value.selectedType, communityContentState = feedPreferenceUiState.value.communityState, personalizedContentState = feedPreferenceUiState.value.personalizedState, - onTypeSelected = {}, - onRetryClick = {} + 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 4d89287c0d3..a434eb9c956 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -122,6 +122,7 @@ class PersonalizationViewModel( fun onPageChanged(screen: PersonalizationPage) { when (screen) { PersonalizationPage.INTERESTS -> loadInterestSelectionScreen() + PersonalizationPage.FEED_PREFERENCE -> loadFeedPreferenceScreen() else -> {} } } @@ -135,6 +136,35 @@ 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() + state.update { it.copy(personalizedContent = personalizedContent, personalizedLoading = false) } + } + } + private suspend fun loadTopics() { if (state.value.topics.isNotEmpty()) return @@ -315,7 +345,7 @@ class PersonalizationViewModel( } } - fun retryLoading() { + fun retryInterestsLoading() { val last = state.value.selectedTopics.lastOrNull() if (last != null) { loadArticlesByTopic(topic = last) @@ -324,6 +354,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 { @@ -336,8 +378,8 @@ class PersonalizationViewModel( wikiSite = WikipediaApp.instance.wikiSite ), feedPreferenceRepository = FeedPreferenceRepository( - interestTopicDao = AppDatabase.instance.topicInterestDao(), interestArticleDao = AppDatabase.instance.articleInterestDao(), + wikiSite = WikipediaApp.instance.wikiSite ) ) } diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt new file mode 100644 index 00000000000..7179cbd6219 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt @@ -0,0 +1,10 @@ +package org.wikipedia.feed.personalization.db + +import androidx.room.Embedded +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestTopic + +data class ArticleWithTopic( + @Embedded val article: InterestArticle, + @Embedded(prefix = "topic_") val topic: InterestTopic +) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt index 2350e548c61..fc2ce7efcfe 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt @@ -5,6 +5,7 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import org.wikipedia.feed.personalization.db.ArticleWithTopic import org.wikipedia.feed.personalization.db.entity.InterestArticle import org.wikipedia.page.Namespace @@ -24,4 +25,19 @@ interface InterestArticleDao { @Query("UPDATE InterestArticle SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") suspend fun updateTopic(newTopicId: String, apiTitle: String, lang: String, namespace: Namespace) + + @Query(""" + SELECT + InterestArticle.*, + InterestTopic.topicId AS topic_topicId, + InterestTopic.lang AS topic_lang, + InterestTopic.topicLabel AS topic_topicLabel, + InterestTopic.queryTopicId AS topic_queryTopicId + FROM InterestArticle + INNER JOIN InterestTopic + ON InterestArticle.topicId = InterestTopic.topicId AND InterestArticle.topicLang = InterestTopic.lang + WHERE InterestArticle.lang = :lang + """ + ) + suspend fun getArticlesWithTopic(lang: String): List } 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 index 778350fc959..05a13ba7106 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -1,9 +1,75 @@ package org.wikipedia.feed.personalization.feedpreference +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.personalization.db.dao.InterestArticleDao import org.wikipedia.feed.personalization.db.dao.InterestTopicDao +import org.wikipedia.settings.Prefs +import org.wikipedia.util.StringUtil +import java.time.LocalDate class FeedPreferenceRepository( - private val interestTopicDao: InterestTopicDao, - private val interestArticleDao: InterestArticleDao -) + private val interestArticleDao: InterestArticleDao, + 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(): List { + val articles = interestArticleDao.getArticlesWithTopic(wikiSite.languageCode) + + return articles.map { + FeedPreferenceContent( + title = it.article.displayTitle, + description = it.article.description, + imageUrl = it.article.thumbUrl, + tag = it.topic.topicLabel + ) + } + } + + 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 index ec78c260ca9..99863a2aa34 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt @@ -20,7 +20,6 @@ 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.foundation.text.TextAutoSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults @@ -39,7 +38,6 @@ 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 androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import org.wikipedia.R import org.wikipedia.compose.components.HtmlText @@ -59,7 +57,7 @@ fun FeedPreferenceScreen( communityContentState: FeedContentState, personalizedContentState: FeedContentState, onTypeSelected: (FeedPreferenceType) -> Unit, - onRetryClick: () -> Unit + onRetryClick: (FeedPreferenceType) -> Unit ) { Column( modifier = modifier, @@ -105,7 +103,7 @@ fun FeedPreferenceSection( state: FeedContentState, isSelected: Boolean, feedPreferenceType: FeedPreferenceType, - onRetryClick: () -> Unit, + onRetryClick: (FeedPreferenceType) -> Unit, onSelected: (FeedPreferenceType) -> Unit ) { val transition = rememberInfiniteTransition(label = "feedPreferenceShimmerTransition") @@ -152,7 +150,7 @@ fun FeedPreferenceSection( WikiErrorView( caught = state.message, errorClickEvents = WikiErrorClickEvents( - retryClickListener = onRetryClick + retryClickListener = { onRetryClick(feedPreferenceType) } ) ) } @@ -226,7 +224,7 @@ fun FeedPreferenceArticleCard( .background( when (feedPreferenceType) { FeedPreferenceType.COMMUNITY -> WikipediaTheme.colors.progressiveColor - FeedPreferenceType.PERSONALIZED -> WikipediaTheme.colors.progressiveColor // TODO: Update color once confirmed with design + FeedPreferenceType.PERSONALIZED -> WikipediaTheme.colors.successColor }, shape = RoundedCornerShape(8.dp) ) .padding(horizontal = 12.dp, vertical = 4.dp), @@ -238,25 +236,24 @@ fun FeedPreferenceArticleCard( .padding(16.dp) .weight(1f) ) { - HtmlText( - text = content.title, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium - ), - maxLines = 2, - color = WikipediaTheme.colors.primaryColor, - ) + 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 = 3, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = 10.sp, - maxFontSize = 14.sp, - ) + maxLines = if (!content.title.isNullOrEmpty()) 3 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis ) } else { Spacer(modifier = Modifier.weight(1f)) 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 index 7de5659616d..3379ea4b183 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt @@ -8,7 +8,7 @@ enum class FeedPreferenceType(val titleRes: Int) { } data class FeedPreferenceContent ( - val title: String, + val title: String?, val description: String?, val imageUrl: String?, val tag: String 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/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/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 @@ + From 9e563e3cd87383159cb26c769146fe9868965cd5 Mon Sep 17 00:00:00 2001 From: williamrai Date: Thu, 16 Apr 2026 16:10:00 -0400 Subject: [PATCH 36/39] - lint fixes --- .../personalization/feedpreference/FeedPreferenceRepository.kt | 1 - 1 file changed, 1 deletion(-) 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 index 05a13ba7106..d7423f7b7bb 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -3,7 +3,6 @@ package org.wikipedia.feed.personalization.feedpreference import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.personalization.db.dao.InterestArticleDao -import org.wikipedia.feed.personalization.db.dao.InterestTopicDao import org.wikipedia.settings.Prefs import org.wikipedia.util.StringUtil import java.time.LocalDate From c2f611b63a845632d5f13837a6a944029d32af0f Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 17 Apr 2026 10:00:16 -0400 Subject: [PATCH 37/39] - adds empty state for personalized content - removes db query to get articleWithTopic --- .../java/org/wikipedia/feed/HomeViewModel.kt | 12 +++++-- .../PersonalizationViewModel.kt | 1 + .../personalization/db/ArticleWithTopic.kt | 10 ------ .../db/dao/InterestArticleDao.kt | 16 ---------- .../FeedPreferenceRepository.kt | 11 +------ .../feedpreference/FeedPreferenceScreen.kt | 32 +++++++++++++++++++ .../feedpreference/FeedPreferenceState.kt | 1 + app/src/main/res/values-qq/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 47 insertions(+), 38 deletions(-) delete mode 100644 app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt 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/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt index a434eb9c956..d92bcff54ee 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -87,6 +87,7 @@ private data class PersonalizedViewModelState( personalizedState = when { personalizedLoading -> FeedContentState.Loading personalizedError != null -> FeedContentState.Error(personalizedError) + personalizedContent.isEmpty() -> FeedContentState.Empty else -> FeedContentState.Success(personalizedContent) } ) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt deleted file mode 100644 index 7179cbd6219..00000000000 --- a/app/src/main/java/org/wikipedia/feed/personalization/db/ArticleWithTopic.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.wikipedia.feed.personalization.db - -import androidx.room.Embedded -import org.wikipedia.feed.personalization.db.entity.InterestArticle -import org.wikipedia.feed.personalization.db.entity.InterestTopic - -data class ArticleWithTopic( - @Embedded val article: InterestArticle, - @Embedded(prefix = "topic_") val topic: InterestTopic -) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt index fc2ce7efcfe..2350e548c61 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt @@ -5,7 +5,6 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.wikipedia.feed.personalization.db.ArticleWithTopic import org.wikipedia.feed.personalization.db.entity.InterestArticle import org.wikipedia.page.Namespace @@ -25,19 +24,4 @@ interface InterestArticleDao { @Query("UPDATE InterestArticle SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") suspend fun updateTopic(newTopicId: String, apiTitle: String, lang: String, namespace: Namespace) - - @Query(""" - SELECT - InterestArticle.*, - InterestTopic.topicId AS topic_topicId, - InterestTopic.lang AS topic_lang, - InterestTopic.topicLabel AS topic_topicLabel, - InterestTopic.queryTopicId AS topic_queryTopicId - FROM InterestArticle - INNER JOIN InterestTopic - ON InterestArticle.topicId = InterestTopic.topicId AND InterestArticle.topicLang = InterestTopic.lang - WHERE InterestArticle.lang = :lang - """ - ) - suspend fun getArticlesWithTopic(lang: String): List } 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 index d7423f7b7bb..88309533dbf 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -56,16 +56,7 @@ class FeedPreferenceRepository( } suspend fun getInterests(): List { - val articles = interestArticleDao.getArticlesWithTopic(wikiSite.languageCode) - - return articles.map { - FeedPreferenceContent( - title = it.article.displayTitle, - description = it.article.description, - imageUrl = it.article.thumbUrl, - tag = it.topic.topicLabel - ) - } + return listOf() } fun saveFeedPreferenceSelection(preferenceType: FeedPreferenceType) { 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 index 99863a2aa34..9850be08064 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt @@ -121,6 +121,7 @@ fun FeedPreferenceSection( RadioButton( selected = isSelected, onClick = { onSelected(feedPreferenceType) }, + enabled = state !is FeedContentState.Empty, colors = RadioButtonDefaults.colors( selectedColor = WikipediaTheme.colors.primaryColor, unselectedColor = WikipediaTheme.colors.primaryColor @@ -169,6 +170,17 @@ fun FeedPreferenceSection( } } + 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( @@ -423,3 +435,23 @@ private fun FeedPreferenceScreenErrorPreview() { ) } } + +@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 index 3379ea4b183..a3a172ecf47 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt @@ -16,6 +16,7 @@ data class FeedPreferenceContent ( 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 } diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 14aa6447a2f..2467a96a855 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -2236,4 +2236,5 @@ 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/strings.xml b/app/src/main/res/values/strings.xml index a416e988168..800dd1a0847 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2489,4 +2489,5 @@ 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 From 854c78a1fc5a8f1488768542541db01778c71c1f Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 17 Apr 2026 13:46:20 -0400 Subject: [PATCH 38/39] - adds API call for getting preview for getInterests - code fixes --- .../PersonalizationViewModel.kt | 7 +- .../FeedPreferenceRepository.kt | 56 ++++++++++++++- .../feedpreference/FeedPreferenceScreen.kt | 69 ++++++++++--------- .../feedpreference/FeedPreferenceState.kt | 2 +- .../history/db/HistoryEntryWithImageDao.kt | 4 ++ 5 files changed, 99 insertions(+), 39 deletions(-) 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 d92bcff54ee..9d5562f70e3 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -161,7 +161,10 @@ class PersonalizationViewModel( L.e(throwable) }) { state.update { it.copy(personalizedLoading = true, personalizedError = null) } - val personalizedContent = feedPreferenceRepository.getInterests() + val personalizedContent = feedPreferenceRepository.getInterests( + selectedTopics = state.value.selectedTopics, + selectedArticles = state.value.selectedArticles + ) state.update { it.copy(personalizedContent = personalizedContent, personalizedLoading = false) } } } @@ -379,7 +382,7 @@ class PersonalizationViewModel( wikiSite = WikipediaApp.instance.wikiSite ), feedPreferenceRepository = FeedPreferenceRepository( - interestArticleDao = AppDatabase.instance.articleInterestDao(), + 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 index 88309533dbf..48264cac5c9 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -2,13 +2,15 @@ package org.wikipedia.feed.personalization.feedpreference import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +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 interestArticleDao: InterestArticleDao, + private val historyEntryWithImageDao: HistoryEntryWithImageDao, private val wikiSite: WikiSite ) { suspend fun getCommunityContent(): List { @@ -55,7 +57,55 @@ class FeedPreferenceRepository( ) } - suspend fun getInterests(): List { + suspend fun getInterests( + selectedTopics: List, + selectedArticles: Set + ): List { + // TODO: Confirm behavior with product + if (selectedTopics.isNotEmpty()) { + val content = selectedTopics.take(3).flatMap { topic -> + val response = ServiceFactory.get(wikiSite).getArticlesByTopic(articleTopics = topic.queryTopicId, limit = 1) + 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() } 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 index 9850be08064..1e918cbe89f 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceScreen.kt @@ -107,7 +107,7 @@ fun FeedPreferenceSection( onSelected: (FeedPreferenceType) -> Unit ) { val transition = rememberInfiniteTransition(label = "feedPreferenceShimmerTransition") - + val isPersonalizedContentDisabled = state is FeedContentState.Empty Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -115,24 +115,27 @@ fun FeedPreferenceSection( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp) - .clickable(onClick = { onSelected(feedPreferenceType) }), + .clickable(onClick = { if (!isPersonalizedContentDisabled) onSelected(feedPreferenceType) }), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = isSelected, onClick = { onSelected(feedPreferenceType) }, - enabled = state !is FeedContentState.Empty, + enabled = !isPersonalizedContentDisabled, colors = RadioButtonDefaults.colors( selectedColor = WikipediaTheme.colors.primaryColor, - unselectedColor = 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 = FontWeight.Medium + fontWeight = if (isPersonalizedContentDisabled) FontWeight.Normal else FontWeight.Medium ), - color = WikipediaTheme.colors.primaryColor + color = if (isPersonalizedContentDisabled) WikipediaTheme.colors.inactiveColor else + WikipediaTheme.colors.primaryColor ) } @@ -212,36 +215,36 @@ fun FeedPreferenceArticleCard( modifier = Modifier .height(108.dp) ) { - if (!content.imageUrl.isNullOrEmpty()) { - 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, + 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 - .fillMaxWidth() - .height(108.dp) + .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 ) } - 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 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 index a3a172ecf47..cd128ee075f 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceState.kt @@ -11,7 +11,7 @@ data class FeedPreferenceContent ( val title: String?, val description: String?, val imageUrl: String?, - val tag: String + val tag: String? ) sealed interface FeedContentState { 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) } } From 4660ede02365e832950585f8060b91cfadf29770 Mon Sep 17 00:00:00 2001 From: williamrai Date: Fri, 17 Apr 2026 16:02:20 -0400 Subject: [PATCH 39/39] - api call fixes --- .../feedpreference/FeedPreferenceRepository.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 48264cac5c9..0115c82e98c 100644 --- a/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt +++ b/app/src/main/java/org/wikipedia/feed/personalization/feedpreference/FeedPreferenceRepository.kt @@ -61,10 +61,15 @@ class FeedPreferenceRepository( selectedTopics: List, selectedArticles: Set ): List { - // TODO: Confirm behavior with product if (selectedTopics.isNotEmpty()) { - val content = selectedTopics.take(3).flatMap { topic -> - val response = ServiceFactory.get(wikiSite).getArticlesByTopic(articleTopics = topic.queryTopicId, limit = 1) + 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,