diff --git a/app/build.gradle b/app/build.gradle
index 1a86719c48c..f476f4ad00b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -207,6 +207,7 @@ dependencies {
implementation libs.photoview
implementation libs.balloon
implementation libs.retrofit2.kotlinx.serialization.converter
+ implementation(libs.paging.compose)
implementation libs.android.sdk
implementation libs.android.plugin.annotation.v9
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ccfa3828f1a..458d085d352 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -157,6 +157,10 @@
android:name=".suggestededits.SuggestionsActivity"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustPan" />
+
+
+
+
+
diff --git a/app/src/main/java/org/wikipedia/Constants.kt b/app/src/main/java/org/wikipedia/Constants.kt
index 0372ec7d770..80ab1f59c49 100644
--- a/app/src/main/java/org/wikipedia/Constants.kt
+++ b/app/src/main/java/org/wikipedia/Constants.kt
@@ -108,7 +108,8 @@ object Constants {
USER_CONTRIB_ACTIVITY("userContribActivity"),
EDIT_ADD_IMAGE("editAddImage"),
SUGGESTED_EDITS_RECENT_EDITS("suggestedEditsRecentEdits"),
- ON_THIS_DAY_GAME_ACTIVITY("onThisDayGame")
+ ON_THIS_DAY_GAME_ACTIVITY("onThisDayGame"),
+ ACTIVITY_TAB("activityTab")
}
enum class ImageEditType(name: String) {
diff --git a/app/src/main/java/org/wikipedia/WikipediaApp.kt b/app/src/main/java/org/wikipedia/WikipediaApp.kt
index 1f70072bc6b..8b866793a67 100644
--- a/app/src/main/java/org/wikipedia/WikipediaApp.kt
+++ b/app/src/main/java/org/wikipedia/WikipediaApp.kt
@@ -257,6 +257,8 @@ class WikipediaApp : Application() {
Prefs.tempAccountWelcomeShown = false
Prefs.tempAccountCreateDay = 0L
Prefs.tempAccountDialogShown = false
+ Prefs.impactLastQueryTime = 0
+ Prefs.impactLastResponseBody = emptyMap()
SharedPreferenceCookieManager.instance.clearAllCookies()
MainScope().launch {
AppDatabase.instance.notificationDao().deleteAll()
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabABTest.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabABTest.kt
new file mode 100644
index 00000000000..73daa3b91e3
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabABTest.kt
@@ -0,0 +1,11 @@
+package org.wikipedia.activitytab
+
+import org.wikipedia.analytics.ABTest
+import org.wikipedia.util.ReleaseUtil
+
+class ActivityTabABTest : ABTest("activityTab", GROUP_SIZE_2) {
+ fun isInTestGroup(): Boolean {
+ return group == GROUP_2 &&
+ ReleaseUtil.isPreBetaRelease // TODO: remove before releasing
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabCustomizationActivity.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabCustomizationActivity.kt
new file mode 100644
index 00000000000..6f5eb62700c
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabCustomizationActivity.kt
@@ -0,0 +1,168 @@
+package org.wikipedia.activitytab
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+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.activity.BaseActivity
+import org.wikipedia.compose.components.WikiTopAppBar
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.settings.Prefs
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.DeviceUtil
+
+class ActivityTabCustomizationActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ DeviceUtil.setEdgeToEdge(this)
+ setContent {
+ BaseTheme {
+ CustomizationScreen(
+ onBackButtonClick = {
+ finish()
+ },
+ modules = Prefs.activityTabModules,
+ haveAtLeastOneDonation = Prefs.donationResults.isNotEmpty()
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context): Intent {
+ return Intent(context, ActivityTabCustomizationActivity::class.java)
+ }
+ }
+}
+
+@Composable
+fun CustomizationScreen(
+ modifier: Modifier = Modifier,
+ onBackButtonClick: () -> Unit,
+ modules: ActivityTabModules,
+ haveAtLeastOneDonation: Boolean = false
+) {
+ var currentModules by remember { mutableStateOf(modules) }
+
+ Scaffold(
+ modifier = modifier
+ .safeDrawingPadding(),
+ topBar = {
+ WikiTopAppBar(
+ title = stringResource(R.string.activity_tab_menu_customize),
+ onNavigationClick = onBackButtonClick
+ )
+ },
+ containerColor = WikipediaTheme.colors.backgroundColor,
+ content = { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(paddingValues)
+ .padding(vertical = 16.dp),
+ ) {
+ item {
+ Text(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp),
+ text = stringResource(R.string.activity_tab_menu_customize_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ itemsIndexed(ModuleType.entries) { index, moduleType ->
+ if (moduleType == ModuleType.DONATIONS && !haveAtLeastOneDonation) {
+ return@itemsIndexed
+ }
+ CustomizationScreenSwitch(
+ isChecked = currentModules.isModuleEnabled(moduleType),
+ title = stringResource(moduleType.displayName),
+ onCheckedChange = { isChecked ->
+ currentModules = currentModules.setModuleEnabled(moduleType, isChecked)
+ Prefs.activityTabModules = currentModules
+ }
+ )
+ if (index < ModuleType.entries.size - 1) {
+ HorizontalDivider(
+ color = WikipediaTheme.colors.borderColor
+ )
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable
+private fun CustomizationScreenSwitch(
+ isChecked: Boolean,
+ title: String,
+ onCheckedChange: ((Boolean) -> Unit),
+ modifier: Modifier = Modifier
+) {
+ ListItem(
+ modifier = modifier,
+ colors = ListItemDefaults.colors(
+ containerColor = WikipediaTheme.colors.paperColor
+ ),
+ headlineContent = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = isChecked,
+ onCheckedChange = {
+ onCheckedChange(it)
+ },
+ colors = SwitchDefaults.colors(
+ uncheckedTrackColor = WikipediaTheme.colors.paperColor,
+ uncheckedThumbColor = MaterialTheme.colorScheme.outline,
+ uncheckedBorderColor = MaterialTheme.colorScheme.outline,
+ checkedTrackColor = WikipediaTheme.colors.progressiveColor,
+ checkedThumbColor = WikipediaTheme.colors.paperColor
+ )
+ )
+ }
+ )
+}
+
+@Preview
+@Composable
+private fun CustomizationScreenPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ CustomizationScreen(
+ onBackButtonClick = {},
+ modules = ActivityTabModules()
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt
new file mode 100644
index 00000000000..6565b6206dc
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt
@@ -0,0 +1,777 @@
+package org.wikipedia.activitytab
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.paging.LoadState
+import androidx.paging.PagingData
+import androidx.paging.compose.collectAsLazyPagingItems
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.launch
+import org.wikipedia.Constants
+import org.wikipedia.R
+import org.wikipedia.WikipediaApp
+import org.wikipedia.activity.BaseActivity
+import org.wikipedia.activity.FragmentUtil.getCallback
+import org.wikipedia.activitytab.timeline.ActivitySource
+import org.wikipedia.activitytab.timeline.TimelineDateSeparator
+import org.wikipedia.activitytab.timeline.TimelineModule
+import org.wikipedia.activitytab.timeline.TimelineModuleEmptyView
+import org.wikipedia.auth.AccountUtil
+import org.wikipedia.categories.CategoryActivity
+import org.wikipedia.categories.db.Category
+import org.wikipedia.compose.components.HtmlText
+import org.wikipedia.compose.components.error.WikiErrorClickEvents
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.concurrency.FlowEventBus
+import org.wikipedia.dataclient.WikiSite
+import org.wikipedia.dataclient.growthtasks.GrowthUserImpact
+import org.wikipedia.diff.ArticleEditDetailsActivity
+import org.wikipedia.events.LoggedInEvent
+import org.wikipedia.events.LoggedOutEvent
+import org.wikipedia.events.LoggedOutInBackgroundEvent
+import org.wikipedia.games.onthisday.OnThisDayGameActivity
+import org.wikipedia.games.onthisday.OnThisDayGameViewModel
+import org.wikipedia.history.HistoryEntry
+import org.wikipedia.history.HistoryFragment
+import org.wikipedia.login.LoginActivity
+import org.wikipedia.navtab.NavTab
+import org.wikipedia.page.PageActivity
+import org.wikipedia.page.PageTitle
+import org.wikipedia.settings.Prefs
+import org.wikipedia.suggestededits.SuggestedEditsTasksActivity
+import org.wikipedia.theme.Theme
+import org.wikipedia.usercontrib.UserContribListActivity
+import org.wikipedia.util.FeedbackUtil
+import org.wikipedia.util.UiState
+import org.wikipedia.util.UriUtil
+import java.time.LocalDateTime
+
+class ActivityTabFragment : Fragment() {
+ interface Callback {
+ fun onNavigateTo(navTab: NavTab)
+ }
+
+ private val viewModel: ActivityTabViewModel by viewModels()
+ private val menuProvider = object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.menu_activity_tab_overflow, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return handleMenuItemClick(menuItem)
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ super.onCreateView(inflater, container, savedInstanceState)
+ Prefs.activityTabRedDotShown = true
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ FlowEventBus.events.collectLatest { event ->
+ when (event) {
+ is LoggedInEvent, is LoggedOutEvent, is LoggedOutInBackgroundEvent -> viewModel.loadAll()
+ }
+ }
+ }
+ }
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ BaseTheme {
+ ActivityTabScreen(
+ isLoggedIn = AccountUtil.isLoggedIn,
+ userName = AccountUtil.userName,
+ modules = Prefs.activityTabModules,
+ haveAtLeastOneDonation = Prefs.donationResults.isNotEmpty(),
+ readingHistoryState = viewModel.readingHistoryState.collectAsState().value,
+ donationUiState = viewModel.donationUiState.collectAsState().value,
+ wikiGamesUiState = viewModel.wikiGamesUiState.collectAsState().value,
+ impactUiState = viewModel.impactUiState.collectAsState().value,
+ timelineFlow = viewModel.timelineFlow
+ )
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
+ viewModel.loadAll()
+ requireActivity().invalidateOptionsMenu()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ requireActivity().removeMenuProvider(menuProvider)
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun ActivityTabScreen(
+ isLoggedIn: Boolean,
+ userName: String,
+ modules: ActivityTabModules,
+ haveAtLeastOneDonation: Boolean,
+ readingHistoryState: UiState,
+ donationUiState: UiState,
+ wikiGamesUiState: UiState,
+ impactUiState: UiState,
+ timelineFlow: Flow>
+ ) {
+ val timelineItems = timelineFlow.collectAsLazyPagingItems()
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WikipediaTheme.colors.paperColor),
+ containerColor = WikipediaTheme.colors.paperColor
+ ) { paddingValues ->
+ var isRefreshing by remember { mutableStateOf(false) }
+ val state = rememberPullToRefreshState()
+ if (readingHistoryState is UiState.Success) {
+ isRefreshing = false
+ }
+
+ if (!isLoggedIn) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier.align(Alignment.Center).padding(horizontal = 16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ modifier = Modifier.size(164.dp),
+ painter = painterResource(R.drawable.illustration_activity_tab_logged_out),
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = stringResource(R.string.activity_tab_logged_out_title),
+ style = MaterialTheme.typography.titleSmall,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Button(
+ modifier = Modifier.padding(top = 16.dp),
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.progressiveColor,
+ contentColor = Color.White,
+ ),
+ onClick = {
+ startActivity(
+ LoginActivity.newIntent(
+ requireContext(),
+ LoginActivity.SOURCE_ACTIVITY
+ )
+ )
+ },
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ painter = painterResource(R.drawable.ic_user_avatar),
+ tint = Color.White,
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier.padding(start = 6.dp),
+ text = stringResource(R.string.create_account_button),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ Button(
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.paperColor,
+ contentColor = WikipediaTheme.colors.primaryColor,
+ ),
+ onClick = {
+ startActivity(
+ LoginActivity.newIntent(
+ requireContext(),
+ LoginActivity.SOURCE_ACTIVITY,
+ createAccountFirst = false
+ )
+ )
+ },
+ ) {
+ Text(
+ modifier = Modifier.padding(start = 6.dp),
+ text = stringResource(R.string.menu_login),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ return@Scaffold
+ }
+
+ if (modules.noModulesVisible(haveAtLeastOneDonation)) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier.align(Alignment.Center).padding(horizontal = 16.dp)
+ .verticalScroll(scrollState),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ modifier = Modifier.size(164.dp),
+ painter = painterResource(R.drawable.illustration_activity_tab_empty),
+ contentDescription = null
+ )
+ HtmlText(
+ modifier = Modifier.padding(vertical = 16.dp),
+ text = stringResource(R.string.activity_tab_customize_screen_no_modules_message),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor,
+ linkInteractionListener = {
+ startActivity(ActivityTabCustomizationActivity.newIntent(requireContext()))
+ }
+ )
+ }
+ return@Scaffold
+ }
+ }
+
+ PullToRefreshBox(
+ onRefresh = {
+ isRefreshing = true
+ timelineItems.refresh()
+ viewModel.loadAll()
+ },
+ isRefreshing = isRefreshing,
+ state = state,
+ indicator = {
+ Indicator(
+ state = state,
+ isRefreshing = isRefreshing,
+ modifier = Modifier.align(Alignment.TopCenter),
+ containerColor = WikipediaTheme.colors.paperColor,
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ ) {
+ LazyColumn {
+ if (modules.isModuleVisible(ModuleType.TIME_SPENT) || modules.isModuleVisible(ModuleType.READING_INSIGHTS)) {
+ item {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(paddingValues)
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ WikipediaTheme.colors.paperColor,
+ WikipediaTheme.colors.additionColor
+ )
+ )
+ )
+ ) {
+ ReadingHistoryModule(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ userName = userName,
+ showTimeSpent = modules.isModuleVisible(ModuleType.TIME_SPENT),
+ showInsights = modules.isModuleVisible(ModuleType.READING_INSIGHTS),
+ readingHistoryState = readingHistoryState,
+ onArticlesReadClick = { callback()?.onNavigateTo(NavTab.SEARCH) },
+ onArticlesSavedClick = { callback()?.onNavigateTo(NavTab.READING_LISTS) },
+ onExploreClick = { callback()?.onNavigateTo(NavTab.READING_LISTS) },
+ onCategoryItemClick = { category ->
+ val pageTitle =
+ viewModel.createPageTitleForCategory(category)
+ startActivity(
+ CategoryActivity.newIntent(
+ requireActivity(),
+ pageTitle
+ )
+ )
+ },
+ wikiErrorClickEvents = WikiErrorClickEvents(
+ retryClickListener = {
+ viewModel.loadReadingHistory()
+ }
+ )
+ )
+ }
+ }
+ }
+ item {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(paddingValues)
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ WikipediaTheme.colors.paperColor,
+ WikipediaTheme.colors.additionColor
+ )
+ )
+ )
+ ) {
+ if (modules.isModuleVisible(ModuleType.EDITING_INSIGHTS) || modules.isModuleVisible(ModuleType.IMPACT)) {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp),
+ text = stringResource(R.string.activity_tab_impact),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.EDITING_INSIGHTS)) {
+ EditingInsightsModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ uiState = impactUiState,
+ onPageItemClick = {
+ val entry = HistoryEntry(
+ title = it,
+ source = HistoryEntry.SOURCE_ACTIVITY_TAB
+ )
+ requireActivity().startActivity(
+ PageActivity.newIntentForNewTab(
+ context = requireActivity(),
+ entry = entry,
+ title = it
+ ))
+ },
+ onContributionClick = {
+ requireActivity().startActivity(
+ UserContribListActivity.newIntent(
+ context = requireActivity(),
+ userName = userName
+ ))
+ },
+ onSuggestedEditsClick = {
+ requireActivity().startActivity(
+ SuggestedEditsTasksActivity.newIntent(
+ context = requireActivity()
+ ))
+ },
+ wikiErrorClickEvents = WikiErrorClickEvents(
+ retryClickListener = {
+ viewModel.loadImpact()
+ }
+ )
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.IMPACT)) {
+ ImpactModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ uiState = impactUiState,
+ wikiErrorClickEvents = WikiErrorClickEvents(
+ retryClickListener = {
+ viewModel.loadImpact()
+ }
+ )
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.GAMES) || modules.isModuleVisible(ModuleType.DONATIONS)) {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp),
+ text = stringResource(R.string.activity_tab_highlights),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.GAMES)) {
+ WikiGamesModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ uiState = wikiGamesUiState,
+ onPlayGameCardClick = {
+ requireActivity().startActivity(OnThisDayGameActivity.newIntent(
+ context = requireContext(),
+ invokeSource = Constants.InvokeSource.ACTIVITY_TAB,
+ wikiSite = WikipediaApp.instance.wikiSite
+ ))
+ },
+ onStatsCardClick = {
+ // TODO: link to the stats page when we have the WikiGames home page.
+ requireActivity().startActivity(OnThisDayGameActivity.newIntent(
+ context = requireContext(),
+ invokeSource = Constants.InvokeSource.ACTIVITY_TAB,
+
+ wikiSite = WikipediaApp.instance.wikiSite
+ ))
+ },
+ wikiErrorClickEvents = WikiErrorClickEvents(
+ retryClickListener = {
+ viewModel.loadWikiGamesStats()
+ }
+ )
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.DONATIONS, haveAtLeastOneDonation)) {
+ DonationModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ uiState = donationUiState,
+ onClick = {
+ (requireActivity() as? BaseActivity)?.launchDonateDialog(
+ campaignId = ActivityTabViewModel.CAMPAIGN_ID
+ )
+ }
+ )
+ }
+
+ if (modules.isModuleVisible(ModuleType.DONATIONS, haveAtLeastOneDonation) || modules.isModuleVisible(ModuleType.GAMES) || modules.isModuleVisible(ModuleType.EDITING_INSIGHTS) || modules.isModuleEnabled(ModuleType.IMPACT)) {
+ // Add bottom padding only if at least one of the modules in this gradient box is enabled.
+ Spacer(modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+
+ if (modules.isModuleVisible(ModuleType.TIMELINE)) {
+ if (timelineItems.itemCount == 0) {
+ item {
+ TimelineModuleEmptyView(
+ modifier = Modifier.align(Alignment.Center)
+ .padding(horizontal = 16.dp)
+ .padding(top = 32.dp, bottom = 52.dp)
+ )
+ }
+ return@LazyColumn
+ }
+ items(
+ count = timelineItems.itemCount,
+ ) { index ->
+ when (val displayItem = timelineItems[index]) {
+ is TimelineDisplayItem.DateSeparator -> {
+ TimelineDateSeparator(
+ date = displayItem.date,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .padding(top = 22.dp, bottom = 8.dp)
+ )
+ }
+ is TimelineDisplayItem.TimelineEntry -> {
+ TimelineModule(
+ timelineItem = displayItem.item,
+ onItemClick = { item ->
+ when (item.activitySource) {
+ ActivitySource.EDIT -> {
+ startActivity(
+ ArticleEditDetailsActivity.newIntent(
+ requireContext(),
+ PageTitle(
+ item.apiTitle,
+ viewModel.wikiSiteForTimeline,
+ item.thumbnailUrl,
+ item.description,
+ item.displayTitle
+ ), item.pageId, revisionTo = item.id
+ )
+ )
+ }
+
+ ActivitySource.BOOKMARKED -> {
+ val title = item.toPageTitle()
+ val entry = HistoryEntry(
+ title,
+ HistoryEntry.SOURCE_INTERNAL_LINK
+ )
+ startActivity(
+ PageActivity.newIntentForCurrentTab(
+ requireContext(),
+ entry,
+ entry.title
+ )
+ )
+ }
+
+ ActivitySource.LINK, ActivitySource.SEARCH -> {
+ val entry = item.toHistoryEntry()
+ startActivity(
+ PageActivity.newIntentForCurrentTab(
+ requireContext(),
+ entry,
+ entry.title
+ )
+ )
+ }
+
+ else -> {}
+ }
+ }
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+ null -> {}
+ }
+ }
+
+ when (timelineItems.loadState.append) {
+ LoadState.Loading -> {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Preview
+ @Composable
+ fun ActivityTabScreenPreview() {
+ val site = WikiSite("https://en.wikipedia.org/".toUri(), "en")
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ActivityTabScreen(
+ isLoggedIn = true,
+ userName = "User",
+ modules = ActivityTabModules(isDonationsEnabled = true),
+ haveAtLeastOneDonation = true,
+ readingHistoryState = UiState.Success(ActivityTabViewModel.ReadingHistory(
+ timeSpentThisWeek = 12345,
+ articlesReadThisMonth = 123,
+ lastArticleReadTime = LocalDateTime.now(),
+ articlesReadByWeek = listOf(0, 12, 34, 56),
+ articlesSavedThisMonth = 23,
+ lastArticleSavedTime = LocalDateTime.of(2025, 6, 1, 12, 30),
+ articlesSaved = 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 = null, 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 = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null),
+ PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null)
+ ),
+ topCategories = listOf(
+ Category(2025, 1, "Category:Ancient history", "en", 1),
+ Category(2025, 1, "Category:World literature", "en", 1),
+ )
+ )),
+ donationUiState = UiState.Success("5 days ago"),
+ wikiGamesUiState = UiState.Success(OnThisDayGameViewModel.GameStatistics(
+ totalGamesPlayed = 10,
+ averageScore = 4.5,
+ currentStreak = 15,
+ bestStreak = 25
+ )),
+ impactUiState = UiState.Success(GrowthUserImpact(totalEditsCount = 12345)),
+ timelineFlow = emptyFlow()
+ )
+ }
+ }
+
+ @Preview
+ @Composable
+ fun ActivityTabScreenEmptyPreview() {
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ActivityTabScreen(
+ isLoggedIn = true,
+ userName = "User",
+ modules = ActivityTabModules(isDonationsEnabled = true),
+ haveAtLeastOneDonation = false,
+ readingHistoryState = UiState.Success(ActivityTabViewModel.ReadingHistory(
+ timeSpentThisWeek = 0,
+ articlesReadThisMonth = 0,
+ lastArticleReadTime = null,
+ articlesReadByWeek = listOf(0, 0, 0, 0),
+ articlesSavedThisMonth = 0,
+ lastArticleSavedTime = null,
+ articlesSaved = emptyList(),
+ topCategories = emptyList()
+ )),
+ donationUiState = UiState.Success("Unknown"),
+ wikiGamesUiState = UiState.Success(null),
+ impactUiState = UiState.Success(GrowthUserImpact()),
+ timelineFlow = emptyFlow()
+ )
+ }
+ }
+
+ @Preview
+ @Composable
+ fun ActivityTabScreenLoggedOutPreview() {
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ActivityTabScreen(
+ isLoggedIn = false,
+ userName = "User",
+ modules = ActivityTabModules(),
+ haveAtLeastOneDonation = false,
+ readingHistoryState = UiState.Success(ActivityTabViewModel.ReadingHistory(
+ timeSpentThisWeek = 0,
+ articlesReadThisMonth = 0,
+ lastArticleReadTime = null,
+ articlesReadByWeek = listOf(0, 0, 0, 0),
+ articlesSavedThisMonth = 0,
+ lastArticleSavedTime = null,
+ articlesSaved = emptyList(),
+ topCategories = emptyList()
+ )),
+ donationUiState = UiState.Success("Unknown"),
+ wikiGamesUiState = UiState.Success(null),
+ impactUiState = UiState.Success(GrowthUserImpact()),
+ timelineFlow = emptyFlow()
+ )
+ }
+ }
+
+ @Preview
+ @Composable
+ fun ActivityTabNoModulesPreview() {
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ActivityTabScreen(
+ isLoggedIn = true,
+ userName = "User",
+ modules = ActivityTabModules(
+ isTimeSpentEnabled = false,
+ isReadingInsightsEnabled = false,
+ isEditingInsightsEnabled = false,
+ isImpactEnabled = false,
+ isGamesEnabled = false,
+ isDonationsEnabled = false,
+ isTimelineEnabled = false
+ ),
+ haveAtLeastOneDonation = true,
+ readingHistoryState = UiState.Success(ActivityTabViewModel.ReadingHistory(
+ timeSpentThisWeek = 0,
+ articlesReadThisMonth = 0,
+ lastArticleReadTime = null,
+ articlesReadByWeek = listOf(0, 0, 0, 0),
+ articlesSavedThisMonth = 0,
+ lastArticleSavedTime = null,
+ articlesSaved = emptyList(),
+ topCategories = emptyList()
+ )),
+ donationUiState = UiState.Success("Unknown"),
+ wikiGamesUiState = UiState.Success(null),
+ impactUiState = UiState.Success(GrowthUserImpact()),
+ timelineFlow = emptyFlow()
+ )
+ }
+ }
+
+ companion object {
+ fun newInstance(): ActivityTabFragment {
+ return ActivityTabFragment().apply {
+ arguments = Bundle().apply {
+ // TODO
+ }
+ }
+ }
+ }
+
+ private fun handleMenuItemClick(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.menu_customize_activity_tab -> {
+ startActivity(ActivityTabCustomizationActivity.newIntent(requireContext()))
+ true
+ }
+ R.id.menu_clear_history -> {
+ HistoryFragment.clearAllHistory(requireContext(), lifecycleScope) {
+ viewModel.loadAll()
+ }
+ true
+ }
+ R.id.menu_learn_more -> {
+ UriUtil.visitInExternalBrowser(requireActivity(), getString(R.string.activity_tab_url).toUri())
+ true
+ }
+ R.id.menu_report_feature -> {
+ FeedbackUtil.composeEmail(requireContext(),
+ subject = getString(R.string.activity_tab_report_email_subject),
+ body = getString(R.string.activity_tab_report_email_body))
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun callback(): Callback? {
+ return getCallback(this, Callback::class.java)
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabModules.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabModules.kt
new file mode 100644
index 00000000000..99b79c0abe1
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabModules.kt
@@ -0,0 +1,52 @@
+package org.wikipedia.activitytab
+
+import kotlinx.serialization.Serializable
+import org.wikipedia.R
+
+@Serializable
+data class ActivityTabModules(
+ val isTimeSpentEnabled: Boolean = true,
+ val isReadingInsightsEnabled: Boolean = true,
+ val isEditingInsightsEnabled: Boolean = true,
+ val isImpactEnabled: Boolean = true,
+ val isGamesEnabled: Boolean = true,
+ val isDonationsEnabled: Boolean = false,
+ val isTimelineEnabled: Boolean = true,
+) {
+ fun isModuleEnabled(moduleType: ModuleType): Boolean = when (moduleType) {
+ ModuleType.TIME_SPENT -> isTimeSpentEnabled
+ ModuleType.READING_INSIGHTS -> isReadingInsightsEnabled
+ ModuleType.EDITING_INSIGHTS -> isEditingInsightsEnabled
+ ModuleType.IMPACT -> isImpactEnabled
+ ModuleType.GAMES -> isGamesEnabled
+ ModuleType.DONATIONS -> isDonationsEnabled
+ ModuleType.TIMELINE -> isTimelineEnabled
+ }
+
+ fun setModuleEnabled(moduleType: ModuleType, enabled: Boolean) = when (moduleType) {
+ ModuleType.TIME_SPENT -> copy(isTimeSpentEnabled = enabled)
+ ModuleType.READING_INSIGHTS -> copy(isReadingInsightsEnabled = enabled)
+ ModuleType.EDITING_INSIGHTS -> copy(isEditingInsightsEnabled = enabled)
+ ModuleType.IMPACT -> copy(isImpactEnabled = enabled)
+ ModuleType.GAMES -> copy(isGamesEnabled = enabled)
+ ModuleType.DONATIONS -> copy(isDonationsEnabled = enabled)
+ ModuleType.TIMELINE -> copy(isTimelineEnabled = enabled)
+ }
+
+ fun isModuleVisible(moduleType: ModuleType, haveAtLeastOneDonation: Boolean = false): Boolean = when (moduleType) {
+ ModuleType.DONATIONS -> isModuleEnabled(moduleType) && haveAtLeastOneDonation
+ else -> isModuleEnabled(moduleType)
+ }
+
+ fun noModulesVisible(haveAtLeastOneDonation: Boolean = false) = ModuleType.entries.all { !isModuleVisible(it, haveAtLeastOneDonation) }
+}
+
+enum class ModuleType(val displayName: Int) {
+ TIME_SPENT(R.string.activity_tab_customize_screen_time_spent_switch_title),
+ READING_INSIGHTS(R.string.activity_tab_customize_screen_reading_insights_switch_title),
+ EDITING_INSIGHTS(R.string.activity_tab_customize_screen_editing_insights_switch_title),
+ IMPACT(R.string.activity_tab_customize_screen_impact_switch_title),
+ GAMES(R.string.activity_tab_customize_screen_games_switch_title),
+ DONATIONS(R.string.activity_tab_customize_screen_donations_switch_title),
+ TIMELINE(R.string.activity_tab_customize_screen_timeline_switch_title)
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt
new file mode 100644
index 00000000000..b816b661df5
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt
@@ -0,0 +1,224 @@
+package org.wikipedia.activitytab
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+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.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+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.activity.BaseActivity
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.settings.Prefs
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.DeviceUtil
+import org.wikipedia.util.UriUtil
+
+private val onboardingItems = listOf(
+ OnboardingItem(
+ icon = R.drawable.ic_newsstand_24,
+ title = R.string.activity_tab_onboarding_reading_patterns_title,
+ subTitle = R.string.activity_tab_onboarding_reading_patterns_message
+ ),
+ OnboardingItem(
+ icon = R.drawable.ic_mode_edit_white_24dp,
+ title = R.string.activity_tab_onboarding_impact_title,
+ subTitle = R.string.activity_tab_onboarding_impact_message
+ ),
+ OnboardingItem(
+ icon = R.drawable.ic_outline_stadia_controller_24,
+ title = R.string.activity_tab_onboarding_engage_title,
+ subTitle = R.string.activity_tab_onboarding_engage_message
+ ),
+ OnboardingItem(
+ icon = R.drawable.ic_outline_lock_24,
+ title = R.string.activity_tab_onboarding_stay_in_control_title,
+ subTitle = R.string.activity_tab_onboarding_stay_in_control_message
+ )
+)
+
+class ActivityTabOnboardingActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ DeviceUtil.setEdgeToEdge(this)
+ setContent {
+ BaseTheme {
+ OnboardingScreen(
+ onboardingItems = onboardingItems,
+ onLearnMoreClick = {
+ UriUtil.visitInExternalBrowser(this, getString(R.string.activity_tab_url).toUri())
+ Prefs.isActivityTabOnboardingShown = true
+ },
+ onContinueClick = {
+ Prefs.isActivityTabOnboardingShown = true
+ setResult(RESULT_OK)
+ finish()
+ }
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context): Intent {
+ return Intent(context, ActivityTabOnboardingActivity::class.java)
+ }
+ }
+}
+
+@Composable
+fun OnboardingScreen(
+ modifier: Modifier = Modifier,
+ onboardingItems: List,
+ onLearnMoreClick: () -> Unit,
+ onContinueClick: () -> Unit
+) {
+ Scaffold(
+ modifier = modifier
+ .safeDrawingPadding(),
+ containerColor = WikipediaTheme.colors.paperColor,
+ bottomBar = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = 24.dp,
+ alignment = Alignment.CenterHorizontally
+ )
+ ) {
+ Button(
+ modifier = Modifier
+ .weight(1f),
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.paperColor
+ ),
+ onClick = onLearnMoreClick
+ ) {
+ Text(
+ text = stringResource(R.string.activity_tab_menu_info),
+ style = MaterialTheme.typography.labelLarge,
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+
+ Button(
+ modifier = Modifier
+ .weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.progressiveColor
+ ),
+ onClick = onContinueClick
+ ) {
+ Text(
+ text = stringResource(R.string.onboarding_continue),
+ style = MaterialTheme.typography.labelLarge,
+ color = WikipediaTheme.colors.paperColor
+ )
+ }
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 58.dp, bottom = 32.dp),
+ textAlign = TextAlign.Center,
+ text = stringResource(R.string.activity_tab_onboarding_screen_title),
+ style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Medium),
+ color = WikipediaTheme.colors.primaryColor
+ )
+
+ onboardingItems.forEach { onboardingItem ->
+ ListItem(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .padding(bottom = 16.dp),
+ colors = ListItemDefaults.colors(
+ containerColor = WikipediaTheme.colors.paperColor
+ ),
+ headlineContent = {
+ Text(
+ text = stringResource(onboardingItem.title),
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = WikipediaTheme.colors.primaryColor
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(onboardingItem.subTitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ },
+ leadingContent = {
+ Icon(
+ modifier = Modifier
+ .padding(top = 2.dp),
+ painter = painterResource(onboardingItem.icon),
+ tint = WikipediaTheme.colors.progressiveColor,
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+data class OnboardingItem(
+ val icon: Int,
+ val title: Int,
+ val subTitle: Int
+)
+
+@Preview
+@Composable
+private fun OnboardingScreenPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ OnboardingScreen(
+ onboardingItems = onboardingItems,
+ onLearnMoreClick = {},
+ onContinueClick = {}
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabViewModel.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabViewModel.kt
new file mode 100644
index 00000000000..7d5ae9955dc
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabViewModel.kt
@@ -0,0 +1,257 @@
+package org.wikipedia.activitytab
+
+import android.text.format.DateUtils
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.cachedIn
+import androidx.paging.insertSeparators
+import androidx.paging.map
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.wikipedia.Constants
+import org.wikipedia.WikipediaApp
+import org.wikipedia.activitytab.timeline.HistoryEntryPagingSource
+import org.wikipedia.activitytab.timeline.ReadingListPagingSource
+import org.wikipedia.activitytab.timeline.TimelineItem
+import org.wikipedia.activitytab.timeline.TimelinePagingSource
+import org.wikipedia.activitytab.timeline.TimelineSource
+import org.wikipedia.activitytab.timeline.UserContribPagingSource
+import org.wikipedia.auth.AccountUtil
+import org.wikipedia.categories.db.Category
+import org.wikipedia.database.AppDatabase
+import org.wikipedia.dataclient.Service
+import org.wikipedia.dataclient.ServiceFactory
+import org.wikipedia.dataclient.WikiSite
+import org.wikipedia.dataclient.growthtasks.GrowthUserImpact
+import org.wikipedia.extensions.toLocalDate
+import org.wikipedia.games.onthisday.OnThisDayGameViewModel
+import org.wikipedia.json.JsonUtil
+import org.wikipedia.page.PageTitle
+import org.wikipedia.readinglist.database.ReadingListPage
+import org.wikipedia.settings.Prefs
+import org.wikipedia.util.UiState
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import kotlin.math.abs
+
+class ActivityTabViewModel() : ViewModel() {
+ private val _readingHistoryState = MutableStateFlow>(UiState.Loading)
+ val readingHistoryState: StateFlow> = _readingHistoryState.asStateFlow()
+
+ private val _donationUiState = MutableStateFlow>(UiState.Loading)
+ val donationUiState: StateFlow> = _donationUiState.asStateFlow()
+
+ private val _wikiGamesUiState = MutableStateFlow>(UiState.Loading)
+ val wikiGamesUiState: StateFlow> = _wikiGamesUiState.asStateFlow()
+
+ private var currentTimelinePagingSource: TimelinePagingSource? = null
+
+ val wikiSiteForTimeline get(): WikiSite {
+ val langCode = Prefs.userContribFilterLangCode
+ return when (langCode) {
+ Constants.WIKI_CODE_COMMONS -> WikiSite(Service.COMMONS_URL)
+ Constants.WIKI_CODE_WIKIDATA -> WikiSite(Service.WIKIDATA_URL)
+ else -> WikiSite.forLanguageCode(langCode)
+ }
+ }
+ val timelineFlow = Pager(
+ config = PagingConfig(
+ pageSize = 150,
+ prefetchDistance = 20
+ ),
+ pagingSourceFactory = { TimelinePagingSource(
+ createTimelineSources()
+ ).also {
+ currentTimelinePagingSource = it
+ } }
+ ).flow.cachedIn(viewModelScope)
+ .map { pagingData ->
+ pagingData.insertSeparators { before, after ->
+ if (before == null && after != null) TimelineDisplayItem.DateSeparator(after.timestamp)
+ else if (before != null && after != null && before.timestamp.toLocalDate() != after.timestamp.toLocalDate()) {
+ TimelineDisplayItem.DateSeparator(after.timestamp)
+ } else null
+ }.map { item ->
+ when (item) {
+ is TimelineItem -> TimelineDisplayItem.TimelineEntry(item)
+ else -> item as TimelineDisplayItem
+ }
+ }
+ }
+
+ private val _impactUiState = MutableStateFlow>(UiState.Loading)
+ val impactUiState: StateFlow> = _impactUiState.asStateFlow()
+
+ fun loadAll() {
+ loadReadingHistory()
+ loadDonationResults()
+ loadWikiGamesStats()
+ loadImpact()
+ refreshTimeline()
+ }
+
+ private fun refreshTimeline() {
+ currentTimelinePagingSource?.invalidate()
+ }
+
+ fun loadReadingHistory() {
+ viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
+ _readingHistoryState.value = UiState.Error(throwable)
+ }) {
+ _readingHistoryState.value = UiState.Loading
+ val now = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ val weekInMillis = TimeUnit.DAYS.toMillis(7)
+ var weekAgo = now - weekInMillis
+ val totalTimeSpent = AppDatabase.instance.historyEntryWithImageDao().getTimeSpentSinceTimeStamp(weekAgo)
+
+ val thirtyDaysAgo = now - TimeUnit.DAYS.toMillis(30)
+ val articlesReadThisMonth = AppDatabase.instance.historyEntryDao().getDistinctEntriesSince(thirtyDaysAgo) ?: 0
+ val articlesReadByWeek = mutableListOf()
+ articlesReadByWeek.add(AppDatabase.instance.historyEntryDao().getDistinctEntriesSince(weekAgo) ?: 0)
+ for (i in 1..3) {
+ weekAgo -= weekInMillis
+ val articlesRead = AppDatabase.instance.historyEntryDao().getDistinctEntriesBetween(weekAgo, weekAgo + weekInMillis)
+ articlesReadByWeek.add(articlesRead)
+ }
+ val mostRecentReadTime = AppDatabase.instance.historyEntryDao().getMostRecentEntry()?.timestamp?.toInstant()?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
+
+ val articlesSavedThisMonth = AppDatabase.instance.readingListPageDao().getTotalPagesSince(thirtyDaysAgo) ?: 0
+ val articlesSaved = AppDatabase.instance.readingListPageDao().getPagesSince(thirtyDaysAgo, 4)
+ .map { ReadingListPage.toPageTitle(it) }
+ val mostRecentSaveTime = AppDatabase.instance.readingListPageDao().getMostRecentSavedPage()?.mtime?.let { Instant.ofEpochMilli(it) }?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
+
+ val currentDate = LocalDate.now()
+ val topCategories = AppDatabase.instance.categoryDao().getTopCategoriesByMonth(currentDate.year, currentDate.monthValue)
+
+ _readingHistoryState.value = UiState.Success(ReadingHistory(
+ timeSpentThisWeek = totalTimeSpent,
+ articlesReadThisMonth = articlesReadThisMonth,
+ lastArticleReadTime = mostRecentReadTime,
+ articlesReadByWeek = articlesReadByWeek,
+ articlesSavedThisMonth = articlesSavedThisMonth,
+ lastArticleSavedTime = mostRecentSaveTime,
+ articlesSaved = articlesSaved,
+ topCategories.take(3))
+ )
+ }
+ }
+
+ fun loadDonationResults() {
+ val lastDonationTime = Prefs.donationResults.lastOrNull()?.dateTime?.let {
+ val timestampInLong = LocalDateTime.parse(it).toInstant(ZoneOffset.UTC).epochSecond
+ val relativeTime = DateUtils.getRelativeTimeSpanString(
+ timestampInLong * 1000, // Convert seconds to milliseconds
+ System.currentTimeMillis(),
+ 0L
+ )
+ return@let relativeTime.toString()
+ }
+ _donationUiState.value = UiState.Success(lastDonationTime)
+ }
+
+ fun loadWikiGamesStats() {
+ viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
+ _wikiGamesUiState.value = UiState.Error(throwable)
+ }) {
+ _wikiGamesUiState.value = UiState.Loading
+ val lastGameHistory = AppDatabase.instance.dailyGameHistoryDao().findLastGameHistory()
+ if (lastGameHistory == null) {
+ _wikiGamesUiState.value = UiState.Success(null)
+ return@launch
+ }
+
+ val gamesStats =
+ OnThisDayGameViewModel.getGameStatistics(WikipediaApp.instance.wikiSite.languageCode)
+ _wikiGamesUiState.value = UiState.Success(gamesStats)
+ }
+ }
+
+ fun loadImpact() {
+ if (!AccountUtil.isLoggedIn) {
+ return
+ }
+ viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
+ _impactUiState.value = UiState.Error(throwable)
+ }) {
+ _impactUiState.value = UiState.Loading
+
+ // The impact API is rate limited, so we cache it manually.
+ val wikiSite = WikipediaApp.instance.wikiSite
+ val now = Instant.now().epochSecond
+ val impact: GrowthUserImpact
+ val impactLastResponseBodyMap = Prefs.impactLastResponseBody.toMutableMap()
+ val impactResponse = impactLastResponseBodyMap[wikiSite.languageCode]
+ if (impactResponse.isNullOrEmpty() || abs(now - Prefs.impactLastQueryTime) > TimeUnit.HOURS.toSeconds(12)) {
+ val userId = ServiceFactory.get(wikiSite).getUserInfo().query?.userInfo?.id!!
+ impact = ServiceFactory.getCoreRest(wikiSite).getUserImpact(userId)
+ impactLastResponseBodyMap[wikiSite.languageCode] = JsonUtil.encodeToString(impact).orEmpty()
+ Prefs.impactLastResponseBody = impactLastResponseBodyMap
+ Prefs.impactLastQueryTime = now
+ } else {
+ impact = JsonUtil.decodeFromString(impactResponse)!!
+ }
+
+ val pagesResponse = ServiceFactory.get(wikiSite).getInfoByPageIdsOrTitles(
+ titles = impact.topViewedArticles.keys.joinToString(separator = "|")
+ )
+
+ // Transform the response to a map of PageTitle to ArticleViews
+ val pageMap = pagesResponse.query?.pages?.associate { page ->
+ val pageTitle = PageTitle(
+ text = page.title,
+ wiki = wikiSite,
+ thumbUrl = page.thumbUrl(),
+ description = page.description,
+ displayText = page.displayTitle(wikiSite.languageCode)
+ )
+ pageTitle to impact.topViewedArticles[pageTitle.text]!!
+ } ?: emptyMap()
+
+ impact.topViewedArticlesWithPageTitle = pageMap
+
+ _impactUiState.value = UiState.Success(impact)
+ }
+ }
+
+ fun createPageTitleForCategory(category: Category): PageTitle {
+ return PageTitle(title = category.title, wiki = WikiSite.forLanguageCode(category.lang))
+ }
+
+ private fun createTimelineSources(): List {
+ val historyEntryPagingSource = HistoryEntryPagingSource(AppDatabase.instance.historyEntryWithImageDao())
+ val userContribPagingSource = UserContribPagingSource(wikiSiteForTimeline, AccountUtil.userName, AppDatabase.instance.historyEntryWithImageDao())
+ val readingListPagingSource = ReadingListPagingSource(AppDatabase.instance.readingListPageDao())
+ return listOf(historyEntryPagingSource, readingListPagingSource, userContribPagingSource)
+ }
+
+ class ReadingHistory(
+ val timeSpentThisWeek: Long,
+ val articlesReadThisMonth: Int,
+ val lastArticleReadTime: LocalDateTime?,
+ val articlesReadByWeek: List,
+ val articlesSavedThisMonth: Int,
+ val lastArticleSavedTime: LocalDateTime?,
+ val articlesSaved: List,
+ val topCategories: List
+ )
+
+ companion object {
+ const val CAMPAIGN_ID = "appmenu_activity"
+ }
+}
+
+sealed class TimelineDisplayItem {
+ data class DateSeparator(val date: Date) : TimelineDisplayItem()
+ data class TimelineEntry(val item: TimelineItem) : TimelineDisplayItem()
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/DonationModule.kt b/app/src/main/java/org/wikipedia/activitytab/DonationModule.kt
new file mode 100644
index 00000000000..875668fd3b2
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/DonationModule.kt
@@ -0,0 +1,112 @@
+package org.wikipedia.activitytab
+
+import androidx.compose.foundation.BorderStroke
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+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.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 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.util.UiState
+
+@Composable
+fun DonationModule(
+ modifier: Modifier = Modifier,
+ uiState: UiState,
+ onClick: (() -> Unit)? = null
+) {
+ WikiCard(
+ modifier = modifier,
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ onClick = onClick
+ ) {
+ if (uiState == UiState.Loading) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(24.dp),
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ } else if (uiState is UiState.Success) {
+ val lastDonationTime = uiState.data ?: stringResource(R.string.activity_tab_donation_unknown)
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.outline_credit_card_heart_24),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ HtmlText(
+ text = stringResource(R.string.activity_tab_donation_last_donation),
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal),
+ color = WikipediaTheme.colors.primaryColor,
+ lineHeight = MaterialTheme.typography.labelMedium.lineHeight
+ )
+ }
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_chevron_forward_white_24dp),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ }
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = lastDonationTime,
+ style = MaterialTheme.typography.titleLarge,
+ color = WikipediaTheme.colors.progressiveColor,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun DonationModulePreview() {
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ DonationModule(
+ uiState = UiState.Success("5 days ago")
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/EditingInsightsModule.kt b/app/src/main/java/org/wikipedia/activitytab/EditingInsightsModule.kt
new file mode 100644
index 00000000000..f31889bf483
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/EditingInsightsModule.kt
@@ -0,0 +1,541 @@
+package org.wikipedia.activitytab
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+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
+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.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
+import coil3.compose.AsyncImage
+import org.wikipedia.R
+import org.wikipedia.compose.components.HtmlText
+import org.wikipedia.compose.components.LineChart
+import org.wikipedia.compose.components.WikiCard
+import org.wikipedia.compose.components.error.WikiErrorClickEvents
+import org.wikipedia.compose.components.error.WikiErrorView
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.dataclient.WikiSite
+import org.wikipedia.dataclient.growthtasks.GrowthUserImpact
+import org.wikipedia.dataclient.growthtasks.GrowthUserImpact.ArticleViews
+import org.wikipedia.page.PageTitle
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.UiState
+import org.wikipedia.views.imageservice.ImageService
+import java.text.NumberFormat
+import java.util.Locale
+
+@Composable
+fun EditingInsightsModule(
+ modifier: Modifier = Modifier,
+ uiState: UiState,
+ onPageItemClick: (PageTitle) -> Unit,
+ onContributionClick: (() -> Unit),
+ onSuggestedEditsClick: (() -> Unit),
+ wikiErrorClickEvents: WikiErrorClickEvents? = null
+) {
+ when (uiState) {
+ UiState.Loading -> {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(24.dp),
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ is UiState.Success -> {
+ MostViewedCard(
+ modifier = modifier
+ .fillMaxWidth(),
+ data = uiState.data.topViewedArticlesWithPageTitle,
+ onClick = {
+ onPageItemClick(it)
+ }
+ )
+ ContributionCard(
+ modifier = modifier
+ .fillMaxWidth(),
+ lastEditRelativeTime = uiState.data.lastEditRelativeTime,
+ editsThisMonth = uiState.data.editsThisMonth,
+ editsLastMonth = uiState.data.editsLastMonth,
+ onContributionClick = {
+ onContributionClick()
+ },
+ onSuggestedEditsClick = {
+ onSuggestedEditsClick()
+ }
+ )
+ }
+
+ is UiState.Error -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ WikiErrorView(
+ modifier = Modifier
+ .fillMaxWidth(),
+ caught = uiState.error,
+ errorClickEvents = wikiErrorClickEvents,
+ retryForGenericError = true
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun MostViewedCard(
+ modifier: Modifier = Modifier,
+ data: Map,
+ showSize: Int = 3,
+ onClick: (PageTitle) -> Unit
+) {
+ if (data.isEmpty()) {
+ return
+ }
+ val formatter = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
+ WikiCard(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = WikipediaTheme.colors.paperColor,
+ contentColor = WikipediaTheme.colors.paperColor
+ ),
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ )
+ ) {
+ Column {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.outline_trending_up_24),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_impact_most_viewed),
+ style = MaterialTheme.typography.labelMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ var index = 1
+ data.forEach { (pageTitle, articleViews) ->
+ if (index > showSize) return@forEach
+ var iconResource = when (index++) {
+ 1 -> R.drawable.outline_looks_one_24
+ 2 -> R.drawable.outline_looks_two_24
+ 3 -> R.drawable.outline_looks_three_24
+ else -> null
+ }
+ if (data.size <= 1) {
+ iconResource = null
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onClick(pageTitle) })
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ iconResource?.let {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(it),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ }
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ ) {
+ HtmlText(
+ text = pageTitle.displayText,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontFamily = FontFamily.Serif
+ ),
+ color = WikipediaTheme.colors.primaryColor,
+ )
+ pageTitle.description?.let { description ->
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LineChart(
+ map = articleViews.viewsByDay,
+ modifier = Modifier
+ .width(24.dp)
+ .height(6.dp),
+ chartSampleSize = 10,
+ strokeWidth = 1.dp,
+ strokeColor = WikipediaTheme.colors.progressiveColor
+ )
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 8.dp),
+ text = formatter.format(articleViews.viewsCount),
+ style = MaterialTheme.typography.labelSmall,
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ if (!pageTitle.thumbUrl.isNullOrEmpty()) {
+ val request =
+ ImageService.getRequest(
+ LocalContext.current,
+ url = pageTitle.thumbUrl
+ )
+ AsyncImage(
+ model = request,
+ placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ }
+ }
+ }
+ if (index < data.size - 1) {
+ HorizontalDivider(
+ color = WikipediaTheme.colors.borderColor
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ContributionCard(
+ modifier: Modifier = Modifier,
+ lastEditRelativeTime: String,
+ editsThisMonth: Int,
+ editsLastMonth: Int,
+ onContributionClick: (() -> Unit)? = null,
+ onSuggestedEditsClick: (() -> Unit)? = null
+) {
+ WikiCard(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = WikipediaTheme.colors.paperColor,
+ contentColor = WikipediaTheme.colors.paperColor
+ ),
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ onClick = onContributionClick
+ ) {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .padding(top = 16.dp, start = 16.dp, end = 16.dp)
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.ic_icon_user_contributions_ooui),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_impact_contributions_this_month),
+ style = MaterialTheme.typography.labelMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ if (editsThisMonth > 0) {
+ Text(
+ text = lastEditRelativeTime,
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ }
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_chevron_forward_white_24dp),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ ) {
+ val maxEditsCount = maxOf(editsThisMonth, editsLastMonth).toFloat()
+
+ ContributionEditsView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ text = pluralStringResource(
+ R.plurals.activity_tab_impact_edits_this_month,
+ editsThisMonth
+ ).lowercase(),
+ edits = editsThisMonth,
+ maxEdits = maxEditsCount,
+ barColor = WikipediaTheme.colors.successColor
+ )
+
+ ContributionEditsView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ text = pluralStringResource(
+ R.plurals.activity_tab_impact_edits_last_month,
+ editsLastMonth
+ ).lowercase(),
+ edits = editsLastMonth,
+ maxEdits = maxEditsCount,
+ barColor = WikipediaTheme.colors.borderColor
+ )
+ }
+
+ if (editsThisMonth == 0) {
+
+ HorizontalDivider(
+ Modifier.padding(horizontal = 16.dp),
+ color = WikipediaTheme.colors.borderColor
+ )
+
+ SuggestedEditsView(
+ modifier = modifier.fillMaxWidth(),
+ onClick = {
+ onSuggestedEditsClick?.invoke()
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SuggestedEditsView(
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(bottom = 8.dp),
+ text = stringResource(R.string.activity_tab_impact_suggested_edits_title),
+ style = MaterialTheme.typography.titleSmall.copy(
+ fontWeight = FontWeight.Medium
+ ),
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ textAlign = TextAlign.Center,
+ text = stringResource(R.string.activity_tab_impact_suggested_edits_message),
+ style = MaterialTheme.typography.bodyMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Button(
+ modifier = Modifier.padding(top = 16.dp).align(Alignment.CenterHorizontally),
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.progressiveColor,
+ contentColor = Color.White,
+ ),
+ onClick = { onClick?.invoke() },
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ painter = painterResource(R.drawable.ic_mode_edit_white_24dp),
+ tint = Color.White,
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 4.dp),
+ text = stringResource(R.string.activity_tab_impact_suggested_edits_button),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+}
+
+@Composable
+fun ContributionEditsView(
+ modifier: Modifier,
+ text: String,
+ edits: Int,
+ maxEdits: Float,
+ barColor: Color
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ text = edits.toString(),
+ modifier = Modifier.padding(start = 16.dp).align(Alignment.Bottom),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontWeight = FontWeight.Medium,
+ lineHeight = 22.sp
+ ),
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+
+ if (edits > 0) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(edits.toFloat() / maxEdits)
+ .padding(horizontal = 16.dp)
+ .height(20.dp)
+ .background(
+ color = barColor,
+ shape = RoundedCornerShape(16.dp)
+ )
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MostViewedCardPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ val pageTitle = PageTitle(
+ text = "Test Article",
+ displayText = "Test Article",
+ wiki = WikiSite("en.wikipedia.org".toUri(), "en"),
+ description = "This is a test article",
+ thumbUrl = "https://example.com/thumb.jpg"
+ )
+ val articleViews = ArticleViews(
+ firstEditDate = "2023-01-01",
+ newestEdit = "2023-10-01",
+ imageUrl = "https://example.com/image.jpg",
+ viewsCount = 1000
+ )
+ MostViewedCard(
+ data = mapOf(
+ pageTitle to articleViews
+ ),
+ onClick = { },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ContributionCardPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ ContributionCard(
+ modifier = Modifier.fillMaxWidth(),
+ lastEditRelativeTime = "2 days ago",
+ editsThisMonth = 9,
+ editsLastMonth = 2,
+ onContributionClick = null
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ContributionCardWithSuggestedEditsPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ ContributionCard(
+ modifier = Modifier.fillMaxWidth(),
+ lastEditRelativeTime = "2024 Sep 1",
+ editsThisMonth = 0,
+ editsLastMonth = 0,
+ onContributionClick = null
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ImpactModule.kt b/app/src/main/java/org/wikipedia/activitytab/ImpactModule.kt
new file mode 100644
index 00000000000..79cd075f3dc
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ImpactModule.kt
@@ -0,0 +1,406 @@
+package org.wikipedia.activitytab
+
+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.Row
+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.shape.RoundedCornerShape
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+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.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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.wikipedia.R
+import org.wikipedia.compose.components.LineChart
+import org.wikipedia.compose.components.WikiCard
+import org.wikipedia.compose.components.error.WikiErrorClickEvents
+import org.wikipedia.compose.components.error.WikiErrorView
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.dataclient.growthtasks.GrowthUserImpact
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.DateUtil
+import org.wikipedia.util.UiState
+import java.text.NumberFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun ImpactModule(
+ modifier: Modifier = Modifier,
+ uiState: UiState,
+ wikiErrorClickEvents: WikiErrorClickEvents? = null
+) {
+ when (uiState) {
+ UiState.Loading -> {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(24.dp),
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ is UiState.Success -> {
+ AllTimeImpactCard(
+ modifier = modifier
+ .fillMaxWidth(),
+ totalEdits = uiState.data.totalEditsCount,
+ totalThanks = uiState.data.receivedThanksCount,
+ longestEditingStreak = uiState.data.longestEditingStreak?.totalEditCountForPeriod ?: 0,
+ lastEditTimestamp = uiState.data.lastEditTimestamp,
+ lastThirtyDaysEdits = uiState.data.lastThirtyDaysEdits,
+ totalPageviewsCount = uiState.data.totalPageviewsCount,
+ dailyTotalViews = uiState.data.dailyTotalViews
+ )
+ }
+
+ is UiState.Error -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ WikiErrorView(
+ modifier = Modifier
+ .fillMaxWidth(),
+ caught = uiState.error,
+ errorClickEvents = wikiErrorClickEvents,
+ retryForGenericError = true
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AllTimeImpactCard(
+ modifier: Modifier = Modifier,
+ totalEdits: Int = 0,
+ totalThanks: Int = 0,
+ longestEditingStreak: Int = 0,
+ lastEditTimestamp: Long = 0,
+ lastThirtyDaysEdits: Map = emptyMap(),
+ totalPageviewsCount: Long = 0,
+ dailyTotalViews: Map = emptyMap()
+) {
+ val formatter = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
+ WikiCard(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = WikipediaTheme.colors.paperColor,
+ contentColor = WikipediaTheme.colors.paperColor
+ ),
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.activity_tab_impact_all_time),
+ style = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = WikipediaTheme.colors.primaryColor,
+ lineHeight = MaterialTheme.typography.labelMedium.lineHeight
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ImpactStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.ic_mode_edit_white_24dp,
+ statValue = totalEdits.toString(),
+ statLabel = pluralStringResource(R.plurals.activity_tab_impact_total_edits, totalEdits)
+ )
+ val bestStreakString = if (longestEditingStreak > 0) {
+ pluralStringResource(R.plurals.activity_tab_impact_best_streak_text, longestEditingStreak, longestEditingStreak)
+ } else {
+ "-"
+ }
+ ImpactStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.baseline_stars_24,
+ statValue = bestStreakString,
+ statLabel = stringResource(R.string.activity_tab_impact_best_streak)
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ImpactStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.ic_notification_thanks,
+ statValue = totalThanks.toString(),
+ statLabel = pluralStringResource(R.plurals.activity_tab_impact_thanks, totalThanks)
+ )
+ val lastEditedDateString = if (lastEditTimestamp > 0) {
+ DateUtil.getMDYDateString(Date(lastEditTimestamp * 1000))
+ } else {
+ "-"
+ }
+ ImpactStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.edit_history_ooui,
+ statValue = lastEditedDateString,
+ statLabel = stringResource(R.string.activity_tab_impact_last_edited)
+ )
+ }
+
+ if (totalEdits > 0) {
+ HorizontalDivider(
+ modifier = Modifier.padding(top = 16.dp),
+ color = WikipediaTheme.colors.borderColor
+ )
+
+ Column(
+ modifier = Modifier.padding(top = 16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.activity_tab_impact_recent_activity),
+ style = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = WikipediaTheme.colors.primaryColor,
+ lineHeight = MaterialTheme.typography.labelMedium.lineHeight
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ val recentEditsCount = lastThirtyDaysEdits.values.sum()
+ Text(
+ text = recentEditsCount.toString(),
+ modifier = Modifier.align(Alignment.Bottom),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = pluralStringResource(
+ R.plurals.activity_tab_impact_recent_activity_edits,
+ recentEditsCount
+ ).lowercase(),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontWeight = FontWeight.Medium,
+ lineHeight = 22.sp
+ ),
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ val totalSize = lastThirtyDaysEdits.size
+ lastThirtyDaysEdits.forEach { (_, editCount) ->
+ val pileColor = if (editCount > 0) {
+ WikipediaTheme.colors.progressiveColor
+ } else {
+ WikipediaTheme.colors.borderColor
+ }
+ Box(
+ modifier = Modifier
+ .weight(1f / totalSize)
+ .height(24.dp)
+ .padding(horizontal = 1.dp)
+ .background(color = pileColor, shape = RoundedCornerShape(8.dp))
+ )
+ }
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = DateUtil.getMonthOnlyDateStringFromTimeString(lastThirtyDaysEdits.keys.first()),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ Text(
+ text = DateUtil.getMonthOnlyDateStringFromTimeString(lastThirtyDaysEdits.keys.last()),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(top = 16.dp),
+ color = WikipediaTheme.colors.borderColor
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ text = formatter.format(totalPageviewsCount),
+ modifier = Modifier.align(Alignment.Bottom),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = pluralStringResource(
+ R.plurals.activity_tab_impact_recent_activity_views,
+ totalPageviewsCount.toInt()
+ ).lowercase(),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontWeight = FontWeight.Medium,
+ lineHeight = 22.sp
+ ),
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+
+ LineChart(
+ map = dailyTotalViews,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(32.dp)
+ .padding(top = 8.dp),
+ chartSampleSize = 90,
+ strokeWidth = 2.dp,
+ strokeColor = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ImpactStatView(
+ modifier: Modifier,
+ iconResource: Int,
+ statValue: String,
+ statLabel: String
+) {
+ Row(
+ modifier = modifier
+ ) {
+ Icon(
+ modifier = Modifier.size(28.dp),
+ painter = painterResource(iconResource),
+ tint = WikipediaTheme.colors.progressiveColor,
+ contentDescription = null
+ )
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp)
+ ) {
+ Text(
+ text = statValue,
+ style = MaterialTheme.typography.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = statLabel.lowercase(),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun AllTimeImpactCardPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ AllTimeImpactCard(
+ modifier = Modifier.fillMaxWidth(),
+ totalEdits = 123,
+ totalThanks = 56,
+ longestEditingStreak = 15,
+ lastEditTimestamp = System.currentTimeMillis() - 86400000L,
+ lastThirtyDaysEdits = mapOf(
+ "2023-10-01" to 5,
+ "2023-10-02" to 3,
+ "2023-10-03" to 7,
+ "2023-10-04" to 0,
+ "2023-10-05" to 4,
+ "2023-10-06" to 6,
+ "2023-10-07" to 2,
+ "2023-10-08" to 1,
+ "2023-10-09" to 0,
+ "2023-10-10" to 8,
+ "2023-10-11" to 0,
+ "2023-10-12" to 3,
+ "2023-10-13" to 5,
+ "2023-10-14" to 2,
+ "2023-10-15" to 4,
+ "2023-10-16" to 0,
+ "2023-10-17" to 1,
+ "2023-10-18" to 0,
+ "2023-10-19" to 6,
+ "2023-10-20" to 2,
+ "2023-10-21" to 3,
+ "2023-10-22" to 0,
+ "2023-10-23" to 4,
+ "2023-10-24" to 5,
+ "2023-10-25" to 0,
+ "2023-10-26" to 1,
+ "2023-10-27" to 2,
+ "2023-10-28" to 0,
+ "2023-10-29" to 3,
+ "2023-10-30" to 4,
+ "2023-10-31" to 0
+ ),
+ totalPageviewsCount = 7890,
+ dailyTotalViews = mapOf(
+ "2023-10-01" to 100,
+ "2023-10-02" to 150,
+ "2023-10-03" to 200,
+ "2023-10-04" to 120,
+ "2023-10-05" to 180,
+ "2023-10-06" to 220
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/ReadingHistoryModule.kt b/app/src/main/java/org/wikipedia/activitytab/ReadingHistoryModule.kt
new file mode 100644
index 00000000000..f6b5a244d7e
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/ReadingHistoryModule.kt
@@ -0,0 +1,617 @@
+package org.wikipedia.activitytab
+
+import android.text.format.DateFormat
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+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.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
+import coil3.compose.AsyncImage
+import org.wikipedia.R
+import org.wikipedia.categories.db.Category
+import org.wikipedia.compose.ComposeColors
+import org.wikipedia.compose.components.TinyBarChart
+import org.wikipedia.compose.components.WikiCard
+import org.wikipedia.compose.components.error.WikiErrorClickEvents
+import org.wikipedia.compose.components.error.WikiErrorView
+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.theme.Theme
+import org.wikipedia.util.StringUtil
+import org.wikipedia.util.UiState
+import org.wikipedia.views.imageservice.ImageService
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+@Composable
+fun ReadingHistoryModule(
+ modifier: Modifier,
+ userName: String,
+ showTimeSpent: Boolean,
+ showInsights: Boolean,
+ readingHistoryState: UiState,
+ onArticlesReadClick: () -> Unit = {},
+ onArticlesSavedClick: () -> Unit = {},
+ onExploreClick: () -> Unit = {},
+ onCategoryItemClick: (Category) -> Unit = {},
+ wikiErrorClickEvents: WikiErrorClickEvents? = null
+) {
+ Text(
+ text = stringResource(R.string.activity_tab_user_reading, userName),
+ modifier = modifier
+ .padding(top = 16.dp),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Box(
+ modifier = modifier
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ .background(
+ color = WikipediaTheme.colors.additionColor,
+ shape = RoundedCornerShape(8.dp)
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.activity_tab_on_wikipedia_android).uppercase(),
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ letterSpacing = TextUnit(0.8f, TextUnitType.Sp),
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ if (readingHistoryState is UiState.Loading) {
+ CircularProgressIndicator(
+ modifier = modifier.padding(vertical = 16.dp).size(48.dp),
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ } else if (readingHistoryState is UiState.Success) {
+ val readingHistory = readingHistoryState.data
+ val todayDate = LocalDate.now()
+
+ if (showTimeSpent) {
+ Text(
+ text = stringResource(
+ R.string.activity_tab_weekly_time_spent_hm,
+ (readingHistory.timeSpentThisWeek / 3600),
+ ((readingHistory.timeSpentThisWeek / 60) % 60)
+ ),
+ modifier = modifier.padding(top = 12.dp),
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineLarge.copy(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ ComposeColors.Red700,
+ ComposeColors.Orange500,
+ ComposeColors.Yellow500,
+ ComposeColors.Blue300
+ )
+ )
+ ),
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_weekly_time_spent),
+ modifier = modifier
+ .padding(top = 8.dp),
+ style = MaterialTheme.typography.labelLarge,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ if (!showInsights) {
+ Spacer(modifier = Modifier.height(16.dp))
+ return
+ }
+
+ ArticleReadThisMonthCard(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ readingHistory = readingHistory,
+ todayDate = todayDate,
+ onClick = {
+ onArticlesReadClick()
+ }
+ )
+
+ ArticleSavedThisMonthCard(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp),
+ readingHistory = readingHistory,
+ todayDate = todayDate,
+ onClick = {
+ onArticlesSavedClick()
+ }
+ )
+
+ if (readingHistory.topCategories.isNotEmpty()) {
+ TopCategoriesCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
+ categories = readingHistory.topCategories,
+ onClick = {
+ onCategoryItemClick(it)
+ }
+ )
+ }
+
+ if (readingHistory.articlesReadThisMonth == 0) {
+ Text(
+ text = stringResource(R.string.activity_tab_discover_encourage),
+ modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Button(
+ modifier = modifier.padding(top = 8.dp, bottom = 16.dp),
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.progressiveColor,
+ contentColor = Color.White,
+ ),
+ onClick = {
+ onExploreClick()
+ },
+ ) {
+ Text(
+ text = stringResource(R.string.activity_tab_explore_wikipedia),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ } else if (readingHistoryState is UiState.Error) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ WikiErrorView(
+ modifier = Modifier
+ .fillMaxWidth(),
+ caught = readingHistoryState.error,
+ errorClickEvents = wikiErrorClickEvents,
+ retryForGenericError = true
+ )
+ }
+ }
+}
+
+@Composable
+private fun ArticleReadThisMonthCard(
+ modifier: Modifier,
+ readingHistory: ActivityTabViewModel.ReadingHistory,
+ todayDate: LocalDate,
+ onClick: () -> Unit
+) {
+ WikiCard(
+ modifier = modifier,
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ onClick = onClick
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.ic_newsstand_24),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_monthly_articles_read),
+ style = MaterialTheme.typography.labelMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ if (readingHistory.lastArticleReadTime != null) {
+ Text(
+ text = if (todayDate == readingHistory.lastArticleReadTime.toLocalDate())
+ readingHistory.lastArticleReadTime
+ .format(
+ DateTimeFormatter.ofPattern(
+ DateFormat.getBestDateTimePattern(
+ Locale.getDefault(),
+ "hhmm a"
+ )
+ )
+ )
+ else
+ readingHistory.lastArticleReadTime
+ .format(
+ DateTimeFormatter.ofPattern(
+ DateFormat.getBestDateTimePattern(
+ Locale.getDefault(),
+ "MMMM d"
+ )
+ )
+ ),
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ }
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_chevron_forward_white_24dp),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
+ ) {
+ Text(
+ text = readingHistory.articlesReadThisMonth.toString(),
+ modifier = Modifier.align(Alignment.Bottom),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Spacer(modifier = Modifier.weight(1f))
+
+ TinyBarChart(
+ values = readingHistory.articlesReadByWeek,
+ modifier = Modifier.size(
+ 72.dp,
+ if (readingHistory.articlesReadThisMonth == 0) 32.dp else 48.dp
+ ),
+ minColor = ComposeColors.Gray300,
+ maxColor = ComposeColors.Green600
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ArticleSavedThisMonthCard(
+ modifier: Modifier,
+ readingHistory: ActivityTabViewModel.ReadingHistory,
+ todayDate: LocalDate,
+ onClick: () -> Unit
+) {
+ WikiCard(
+ modifier = modifier,
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ onClick = onClick
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.ic_bookmark_border_white_24dp),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_monthly_articles_saved),
+ style = MaterialTheme.typography.labelMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ if (readingHistory.lastArticleSavedTime != null) {
+ Text(
+ text = if (todayDate == readingHistory.lastArticleSavedTime.toLocalDate())
+ readingHistory.lastArticleSavedTime
+ .format(
+ DateTimeFormatter.ofPattern(
+ DateFormat.getBestDateTimePattern(
+ Locale.getDefault(),
+ "hhmm a"
+ )
+ )
+ )
+ else
+ readingHistory.lastArticleSavedTime
+ .format(
+ DateTimeFormatter.ofPattern(
+ DateFormat.getBestDateTimePattern(
+ Locale.getDefault(),
+ "MMMM d"
+ )
+ )
+ ),
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+ }
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_chevron_forward_white_24dp),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
+ ) {
+ Text(
+ text = readingHistory.articlesSavedThisMonth.toString(),
+ modifier = Modifier.align(Alignment.Bottom),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Row {
+ val itemsToShow =
+ if (readingHistory.articlesSaved.size <= 4) readingHistory.articlesSaved.size else 3
+ val showOverflowItem = readingHistory.articlesSaved.size > 4
+
+ for (i in 0 until itemsToShow) {
+ val url = readingHistory.articlesSaved[i].thumbUrl
+ if (url.isNullOrEmpty()) {
+ Box(
+ modifier = Modifier.padding(start = 4.dp).size(38.dp)
+ .background(
+ color = Color.White,
+ shape = RoundedCornerShape(19.dp)
+ ).border(
+ 0.5.dp,
+ WikipediaTheme.colors.borderColor,
+ RoundedCornerShape(19.dp)
+ )
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp).align(Alignment.Center),
+ painter = painterResource(R.drawable.ic_wikipedia_b),
+ tint = Color.Black,
+ contentDescription = null
+ )
+ }
+ } else {
+ val request =
+ ImageService.getRequest(LocalContext.current, url = url)
+ AsyncImage(
+ model = request,
+ placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ modifier = Modifier.padding(start = 4.dp).size(38.dp)
+ .clip(RoundedCornerShape(19.dp))
+ )
+ }
+ }
+
+ if (showOverflowItem) {
+ Box(
+ modifier = Modifier.padding(start = 4.dp).size(38.dp)
+ .background(
+ color = WikipediaTheme.colors.placeholderColor,
+ shape = RoundedCornerShape(19.dp)
+ )
+ ) {
+ Text(
+ text = String.format(
+ Locale.getDefault(),
+ "+%d",
+ readingHistory.articlesSavedThisMonth - 3
+ ),
+ modifier = Modifier.align(Alignment.Center),
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ color = Color.White
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TopCategoriesCard(
+ modifier: Modifier = Modifier,
+ categories: List,
+ onClick: (Category) -> Unit
+) {
+ WikiCard(
+ modifier = modifier,
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ )
+ ) {
+ Column {
+ Row(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ painter = painterResource(R.drawable.ic_category_black_24dp),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ Text(
+ text = stringResource(R.string.activity_tab_monthly_top_categories),
+ style = MaterialTheme.typography.labelMedium,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ categories.forEachIndexed { index, value ->
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onClick(value) })
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(horizontal = 32.dp, vertical = 16.dp),
+ text = StringUtil.removeNamespace(value.title),
+ style = MaterialTheme.typography.bodyLarge,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+
+ if (index < categories.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = WikipediaTheme.colors.borderColor
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ArticleReadThisMonthCardPreview() {
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ArticleReadThisMonthCard(
+ modifier = Modifier
+ .padding(20.dp),
+ readingHistory = ActivityTabViewModel.ReadingHistory(
+ articlesReadThisMonth = 42,
+ articlesSavedThisMonth = 8,
+ timeSpentThisWeek = 3661,
+ lastArticleReadTime = LocalDate.now().atStartOfDay(),
+ lastArticleSavedTime = LocalDate.now().atStartOfDay(),
+ articlesReadByWeek = listOf(5, 10, 8, 12),
+ articlesSaved = listOf(),
+ topCategories = listOf()
+ ),
+ todayDate = LocalDate.now(),
+ onClick = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ArticleSavedThisMonthCardPreview() {
+ val wikiSite = WikiSite("en.wikipedia.org".toUri(), "en")
+ BaseTheme(currentTheme = Theme.LIGHT) {
+ ArticleSavedThisMonthCard(
+ modifier = Modifier
+ .padding(20.dp),
+ readingHistory = ActivityTabViewModel.ReadingHistory(
+ articlesReadThisMonth = 42,
+ articlesSavedThisMonth = 8,
+ timeSpentThisWeek = 3661,
+ lastArticleReadTime = LocalDate.now().atStartOfDay(),
+ lastArticleSavedTime = LocalDate.now().atStartOfDay(),
+ articlesReadByWeek = listOf(5, 10, 8, 12),
+ articlesSaved = listOf(
+ PageTitle("Title1", wikiSite),
+ PageTitle("Title2", wikiSite),
+ PageTitle("Title3", wikiSite),
+ ),
+ topCategories = listOf()
+ ),
+ todayDate = LocalDate.now(),
+ onClick = {}
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun TopCategoriesViewPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ TopCategoriesCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ categories = listOf(
+ Category(2025, 1, "Category:Ancient history", "en", 1),
+ Category(2025, 1, "Category:World literature", "en", 1),
+ Category(2025, 1, "Category:Cat breeds originating in the United States", "en", 1),
+ ),
+ onClick = {}
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/WikiGamesModule.kt b/app/src/main/java/org/wikipedia/activitytab/WikiGamesModule.kt
new file mode 100644
index 00000000000..81a6329068f
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/WikiGamesModule.kt
@@ -0,0 +1,282 @@
+package org.wikipedia.activitytab
+
+import androidx.compose.foundation.BorderStroke
+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.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.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+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.pluralStringResource
+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 org.wikipedia.R
+import org.wikipedia.compose.components.WikiCard
+import org.wikipedia.compose.components.error.WikiErrorClickEvents
+import org.wikipedia.compose.components.error.WikiErrorView
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.games.onthisday.OnThisDayGameViewModel
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.UiState
+
+@Composable
+fun WikiGamesModule(
+ modifier: Modifier = Modifier,
+ uiState: UiState,
+ onPlayGameCardClick: (() -> Unit)? = null,
+ onStatsCardClick: (() -> Unit)? = null,
+ wikiErrorClickEvents: WikiErrorClickEvents? = null
+) {
+ when (uiState) {
+ UiState.Loading -> {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(24.dp),
+ color = WikipediaTheme.colors.progressiveColor
+ )
+ }
+ }
+ is UiState.Success -> {
+ WikiGamesStatsCard(
+ modifier = modifier
+ .fillMaxWidth(),
+ totalGamesPlayed = uiState.data?.totalGamesPlayed ?: 0,
+ currentStreak = uiState.data?.currentStreak ?: 0,
+ bestStreak = uiState.data?.bestStreak ?: 0,
+ averageScore = uiState.data?.averageScore ?: 0.0,
+ onStatsCardClick = onStatsCardClick,
+ onPlayGameCardClick = onPlayGameCardClick
+ )
+ }
+
+ is UiState.Error -> {
+ Box(
+ modifier = modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ WikiErrorView(
+ modifier = Modifier
+ .fillMaxWidth(),
+ caught = uiState.error,
+ errorClickEvents = wikiErrorClickEvents,
+ retryForGenericError = true
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun WikiGamesStatsCard(
+ modifier: Modifier = Modifier,
+ totalGamesPlayed: Int = 0,
+ currentStreak: Int = 0,
+ bestStreak: Int = 0,
+ averageScore: Double = 0.0,
+ onStatsCardClick: (() -> Unit)? = null,
+ onPlayGameCardClick: (() -> Unit)? = null
+) {
+ WikiCard(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = WikipediaTheme.colors.paperColor,
+ contentColor = WikipediaTheme.colors.paperColor
+ ),
+ elevation = 0.dp,
+ border = BorderStroke(
+ width = 1.dp,
+ color = WikipediaTheme.colors.borderColor
+ ),
+ onClick = onStatsCardClick
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = stringResource(R.string.activity_tab_game_stats),
+ style = MaterialTheme.typography.labelMedium.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = WikipediaTheme.colors.primaryColor,
+ lineHeight = MaterialTheme.typography.labelMedium.lineHeight
+ )
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_chevron_forward_white_24dp),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ WikiGamesStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.baseline_extension_24,
+ statValue = totalGamesPlayed.toString(),
+ statLabel = pluralStringResource(R.plurals.on_this_day_game_stats_games_played, totalGamesPlayed)
+ )
+ WikiGamesStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.outline_motion_blur_24,
+ statValue = if (currentStreak == 0) "-" else currentStreak.toString(),
+ statLabel = stringResource(R.string.on_this_day_game_stats_streak)
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ WikiGamesStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.filled_family_star_24,
+ statValue = if (bestStreak == 0) "-" else bestStreak.toString(),
+ statLabel = stringResource(R.string.activity_tab_game_stats_best_streak)
+ )
+ WikiGamesStatView(
+ modifier = Modifier.weight(1f),
+ iconResource = R.drawable.outline_sports_score_24,
+ statValue = if (averageScore == 0.0) "-" else averageScore.toString(),
+ statLabel = stringResource(R.string.on_this_day_game_stats_average_score)
+ )
+ }
+ if (totalGamesPlayed == 0) {
+ Button(
+ modifier = Modifier.padding(top = 16.dp).align(Alignment.CenterHorizontally),
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.progressiveColor,
+ contentColor = Color.White,
+ ),
+ onClick = { onPlayGameCardClick?.invoke() },
+ ) {
+ Text(
+ modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 4.dp),
+ text = stringResource(R.string.activity_tab_play_wiki_games),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun WikiGamesStatView(
+ modifier: Modifier,
+ iconResource: Int,
+ statValue: String,
+ statLabel: String
+) {
+ Row(
+ modifier = modifier
+ ) {
+ Icon(
+ modifier = Modifier.size(28.dp),
+ painter = painterResource(iconResource),
+ tint = WikipediaTheme.colors.progressiveColor,
+ contentDescription = null
+ )
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp)
+ ) {
+ Text(
+ text = statValue,
+ style = MaterialTheme.typography.labelLarge.copy(
+ fontWeight = FontWeight.SemiBold
+ ),
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = statLabel.lowercase(),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WikiGamesModulePreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ WikiGamesModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ uiState = UiState.Success(
+ OnThisDayGameViewModel.GameStatistics(
+ totalGamesPlayed = 43,
+ averageScore = 4.5,
+ currentStreak = 5,
+ bestStreak = 15
+ )
+ ),
+ onPlayGameCardClick = {},
+ onStatsCardClick = {},
+ wikiErrorClickEvents = null
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun WikiGamesModuleWithPlayButtonPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ WikiGamesModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ uiState = UiState.Success(
+ OnThisDayGameViewModel.GameStatistics(
+ totalGamesPlayed = 0,
+ averageScore = 0.0,
+ currentStreak = 0,
+ bestStreak = 0
+ )
+ ),
+ onPlayGameCardClick = {},
+ onStatsCardClick = {},
+ wikiErrorClickEvents = null
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineModule.kt b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineModule.kt
new file mode 100644
index 00000000000..42b3b98c76e
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineModule.kt
@@ -0,0 +1,244 @@
+package org.wikipedia.activitytab.timeline
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+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.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.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+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 coil3.compose.AsyncImage
+import org.wikipedia.R
+import org.wikipedia.compose.components.HtmlText
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.extensions.isToday
+import org.wikipedia.extensions.isYesterday
+import org.wikipedia.theme.Theme
+import org.wikipedia.util.DateUtil
+import org.wikipedia.views.imageservice.ImageService
+import java.util.Date
+
+// @TODO: MARK_ACTIVITY_TAB retrieve description and thumbnail for contributions through API
+@Composable
+fun TimelineModule(
+ modifier: Modifier = Modifier,
+ timelineItem: TimelineItem,
+ onItemClick: (TimelineItem) -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onItemClick(timelineItem) })
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val icon = when (timelineItem.activitySource) {
+ ActivitySource.EDIT -> R.drawable.ic_mode_edit_white_24dp
+ ActivitySource.SEARCH -> R.drawable.search_bold
+ ActivitySource.LINK -> R.drawable.ic_link_black_24dp
+ ActivitySource.BOOKMARKED -> R.drawable.ic_bookmark_white_24dp
+ null -> null
+ }
+ if (icon != null) {
+ Icon(
+ painter = painterResource(icon),
+ tint = WikipediaTheme.colors.primaryColor,
+ contentDescription = null
+ )
+ }
+ Column(
+ modifier = Modifier
+ .weight(1f),
+ verticalArrangement = Arrangement.Center
+ ) {
+ HtmlText(
+ text = timelineItem.displayTitle,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontFamily = FontFamily.Serif,
+ color = WikipediaTheme.colors.primaryColor
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ // Description
+ if (!timelineItem.description.isNullOrEmpty()) {
+ Text(
+ text = timelineItem.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = WikipediaTheme.colors.secondaryColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ if (timelineItem.activitySource == ActivitySource.EDIT) {
+ Button(
+ modifier = modifier.padding(top = 8.dp),
+ contentPadding = PaddingValues(horizontal = 18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WikipediaTheme.colors.additionColor,
+ contentColor = WikipediaTheme.colors.secondaryColor,
+ ),
+ onClick = { onItemClick(timelineItem) },
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ painter = painterResource(R.drawable.outline_difference_24),
+ tint = WikipediaTheme.colors.secondaryColor,
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier.padding(start = 6.dp),
+ text = stringResource(R.string.activity_tab_timeline_view_changes_button)
+ )
+ }
+ }
+ }
+ if (!timelineItem.thumbnailUrl.isNullOrEmpty()) {
+ val request =
+ ImageService.getRequest(LocalContext.current, url = timelineItem.thumbnailUrl)
+ AsyncImage(
+ model = request,
+ placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)),
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ }
+ }
+}
+
+@Composable
+fun TimelineModuleEmptyView(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 48.dp),
+ text = stringResource(R.string.activity_tab_timeline_today),
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleLarge,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Image(
+ modifier = Modifier
+ .size(164.dp),
+ painter = painterResource(R.drawable.illustration_activity_tab_empty),
+ contentDescription = null
+ )
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = stringResource(R.string.activity_tab_timeline_empty_state_title),
+ style = MaterialTheme.typography.titleSmall,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ modifier = Modifier.padding(top = 4.dp),
+ text = stringResource(R.string.activity_tab_timeline_empty_state_message),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ }
+}
+
+// Date Separator Composable
+@Composable
+fun TimelineDateSeparator(
+ date: Date,
+ modifier: Modifier = Modifier
+) {
+ val dateText = when {
+ date.isToday() -> stringResource(R.string.activity_tab_timeline_today)
+ date.isYesterday() -> stringResource(R.string.activity_tab_timeline_yesterday)
+ else -> DateUtil.toRelativeDateString(date)
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = dateText,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = WikipediaTheme.colors.primaryColor
+ )
+ Text(
+ text = DateUtil.getMMMMdYYYY(date, false),
+ style = MaterialTheme.typography.bodySmall,
+ color = WikipediaTheme.colors.secondaryColor
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun TimelineItemPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ TimelineModule(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ timelineItem = TimelineItem(
+ id = 1,
+ pageId = 1,
+ displayTitle = "1980s professional wrestling boxing",
+ description = "Era of professional wrestling",
+ thumbnailUrl = "",
+ timestamp = Date(),
+ source = 1,
+ activitySource = ActivitySource.EDIT
+ ),
+ onItemClick = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun TimelineDateSeparatorPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ TimelineDateSeparator(
+ date = Date()
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/timeline/TimelinePagingSource.kt b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelinePagingSource.kt
new file mode 100644
index 00000000000..5557beb2112
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelinePagingSource.kt
@@ -0,0 +1,42 @@
+package org.wikipedia.activitytab.timeline
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+
+class TimelinePagingSource(
+ private val sources: List
+) : PagingSource() {
+ override fun getRefreshKey(state: PagingState): TimelinePageKey? {
+ return null
+ }
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val key = params.key ?: TimelinePageKey()
+ val allItems = mutableListOf()
+ val newCursors = mutableMapOf()
+
+ sources.forEach { source ->
+ val sourceName = source.id
+ val cursor = key.cursors[sourceName]
+ if (cursor != null || key.cursors.isEmpty()) {
+ val (items, nextCursor) = source.fetch(params.loadSize, cursor)
+ allItems.addAll(items)
+ nextCursor?.let { newCursors[sourceName] = it }
+ }
+ }
+
+ val merged = allItems
+ .distinctBy { it.id }
+ .sortedByDescending { it.timestamp }
+
+ LoadResult.Page(
+ data = merged,
+ prevKey = null,
+ nextKey = if (newCursors.isEmpty()) null else TimelinePageKey(newCursors)
+ )
+ } catch (e: Exception) {
+ LoadResult.Error(e)
+ }
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineRepository.kt b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineRepository.kt
new file mode 100644
index 00000000000..262f59a18b7
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/activitytab/timeline/TimelineRepository.kt
@@ -0,0 +1,196 @@
+package org.wikipedia.activitytab.timeline
+
+import org.wikipedia.dataclient.ServiceFactory
+import org.wikipedia.dataclient.WikiSite
+import org.wikipedia.history.HistoryEntry
+import org.wikipedia.history.HistoryEntry.Companion.SOURCE_SEARCH
+import org.wikipedia.history.db.HistoryEntryWithImageDao
+import org.wikipedia.page.Namespace
+import org.wikipedia.page.PageTitle
+import org.wikipedia.readinglist.db.ReadingListPageDao
+import java.time.ZoneId
+import java.util.Date
+
+interface TimelineSource {
+ val id: String
+ suspend fun fetch(pageSize: Int, cursor: Cursor?): Pair, Cursor?>
+}
+
+class HistoryEntryPagingSource(
+ private val dao: HistoryEntryWithImageDao
+) : TimelineSource {
+
+ override val id: String = "history_entry"
+
+ override suspend fun fetch(pageSize: Int, cursor: Cursor?): Pair, Cursor?> {
+ val offset = (cursor as? Cursor.HistoryEntryCursor)?.offset ?: 0
+ val items = dao.getHistoryEntriesWithOffset(pageSize, offset).map {
+ TimelineItem(
+ id = it.id,
+ pageId = 0,
+ authority = it.authority,
+ apiTitle = it.apiTitle,
+ displayTitle = it.displayTitle,
+ description = it.description,
+ thumbnailUrl = it.imageName,
+ timestamp = it.timestamp,
+ namespace = it.namespace,
+ lang = it.lang,
+ source = it.source,
+ activitySource = when (it.source) {
+ SOURCE_SEARCH -> ActivitySource.SEARCH
+ else -> ActivitySource.LINK
+ }
+ )
+ }
+ val nextCursor =
+ if (items.size < pageSize) null else Cursor.HistoryEntryCursor(offset + items.size)
+ return items to nextCursor
+ }
+}
+
+class UserContribPagingSource(
+ private val wikiSite: WikiSite,
+ private val userName: String,
+ private val historyEntryWithImageDao: HistoryEntryWithImageDao
+) : TimelineSource {
+
+ private val maxBatchSize = 50
+
+ override val id: String = "user_contrib"
+
+ override suspend fun fetch(pageSize: Int, cursor: Cursor?): Pair, Cursor?> {
+ val token = (cursor as? Cursor.UserContribCursor)?.token
+ val service = ServiceFactory.get(wikiSite)
+ val userContribResponse = service.getUserContrib(username = userName, maxCount = pageSize, ns = null, filter = null, uccontinue = token, ucdir = "older")
+
+ val missingPageInfoIds = mutableListOf()
+ val timelineItemsByPageId = mutableMapOf()
+ userContribResponse.query?.userContributions?.forEach { contribution ->
+ // pageId for article namespace and revid for other namespace as key because they can have similar pageId for example (User talk namespace)
+ // Only check database cache for article namespace article
+ val savedHistoryItem = if (contribution.ns == Namespace.MAIN.code()) historyEntryWithImageDao.getHistoryItemWIthImage(contribution.title).firstOrNull() else null
+ val keyForMap = savedHistoryItem?.id ?: contribution.revid
+ val timelineItem = TimelineItem(
+ id = contribution.revid,
+ pageId = contribution.pageid,
+ apiTitle = contribution.title,
+ displayTitle = contribution.title,
+ description = savedHistoryItem?.description,
+ thumbnailUrl = savedHistoryItem?.imageName,
+ timestamp = Date.from(contribution.parsedDateTime.atZone(ZoneId.systemDefault()).toInstant()),
+ activitySource = ActivitySource.EDIT,
+ source = -1
+ )
+ timelineItemsByPageId[keyForMap] = timelineItem
+ // only fetch page info for contribution in article namespace
+ if (contribution.ns == Namespace.MAIN.code() && savedHistoryItem == null) {
+ missingPageInfoIds.add(contribution.pageid)
+ }
+ }
+
+ // Fetching missing page info in batches
+ missingPageInfoIds.chunked(maxBatchSize).forEach { batch ->
+ val pages = service.getInfoByPageIdsOrTitles(pageIds = batch.joinToString(separator = "|")).query?.pages.orEmpty()
+ pages.forEach { page ->
+ timelineItemsByPageId[page.pageId.toLong()]?.let { existingItem ->
+ timelineItemsByPageId[page.pageId.toLong()] = existingItem.copy(
+ description = page.description,
+ thumbnailUrl = page.thumbUrl()
+ )
+ }
+ }
+ }
+
+ val items = timelineItemsByPageId.values.sortedByDescending { it.timestamp }
+
+ val nextCursor = userContribResponse.continuation?.ucContinuation?.let { Cursor.UserContribCursor(it) }
+ return items to nextCursor
+ }
+}
+
+class ReadingListPagingSource(
+ val dao: ReadingListPageDao
+) : TimelineSource {
+
+ override val id: String = "reading_list"
+
+ override suspend fun fetch(pageSize: Int, cursor: Cursor?): Pair, Cursor?> {
+ val offset = (cursor as? Cursor.ReadingListCursor)?.offset ?: 0
+ val items = dao.getPagesWithLimitOffset(pageSize, offset).map {
+ TimelineItem(
+ id = it.mtime + it.atime + it.id,
+ pageId = 0,
+ apiTitle = it.apiTitle,
+ displayTitle = it.displayTitle,
+ description = it.description,
+ thumbnailUrl = it.thumbUrl,
+ timestamp = Date(it.mtime),
+ wiki = WikiSite.forLanguageCode(it.lang),
+ activitySource = ActivitySource.BOOKMARKED,
+ source = -1
+ )
+ }
+ val nextCursor =
+ if (items.size < pageSize) null else Cursor.HistoryEntryCursor(offset + items.size)
+ return items to nextCursor
+ }
+}
+
+// Data Models
+enum class ActivitySource {
+ EDIT, SEARCH, LINK, BOOKMARKED
+}
+
+data class TimelineItem(
+ val id: Long,
+ val pageId: Int,
+ val description: String?,
+ val thumbnailUrl: String?,
+ val timestamp: Date,
+ val source: Int,
+ val activitySource: ActivitySource?,
+ var authority: String = "",
+ var lang: String = "",
+ var apiTitle: String = "",
+ var displayTitle: String = "",
+ var namespace: String = "",
+ val wiki: WikiSite? = null
+) {
+ fun toHistoryEntry(): HistoryEntry {
+ val entry = HistoryEntry(
+ authority = authority,
+ lang = lang,
+ apiTitle = apiTitle,
+ displayTitle = displayTitle,
+ id = id,
+ namespace = namespace,
+ timestamp = timestamp,
+ source = source
+ )
+ entry.title.thumbUrl = thumbnailUrl
+ entry.title.description = description
+
+ return entry
+ }
+
+ fun toPageTitle(): PageTitle {
+ return PageTitle(
+ apiTitle,
+ wiki!!,
+ thumbnailUrl,
+ description,
+ displayTitle
+ )
+ }
+}
+
+sealed class Cursor {
+ data class UserContribCursor(val token: String?) : Cursor()
+ data class HistoryEntryCursor(val offset: Int) : Cursor()
+ data class ReadingListCursor(val offset: Int) : Cursor()
+}
+
+data class TimelinePageKey(
+ val cursors: Map = emptyMap()
+)
diff --git a/app/src/main/java/org/wikipedia/categories/db/CategoryDao.kt b/app/src/main/java/org/wikipedia/categories/db/CategoryDao.kt
index 48920ec9a79..3e8c8a75b62 100644
--- a/app/src/main/java/org/wikipedia/categories/db/CategoryDao.kt
+++ b/app/src/main/java/org/wikipedia/categories/db/CategoryDao.kt
@@ -25,6 +25,9 @@ interface CategoryDao {
@Query("SELECT year, month, title, lang, SUM (count) AS count FROM Category WHERE year BETWEEN :startYear AND :endYear GROUP BY title, lang ORDER BY count DESC")
suspend fun getCategoriesByTimeRange(startYear: Int, endYear: Int): List
+ @Query("SELECT year, month, title, lang, SUM (count) AS count FROM Category WHERE year = :year AND month = :month GROUP BY title, lang ORDER BY count DESC")
+ suspend fun getTopCategoriesByMonth(year: Int, month: Int): List
+
@Query("SELECT * FROM Category")
suspend fun getAllCategories(): List
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 92dee2d2a3e..83fd52d22f0 100644
--- a/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt
+++ b/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt
@@ -9,6 +9,7 @@ import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
+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.TextUnit
@@ -37,7 +38,8 @@ fun HtmlText(
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Ellipsis,
lineHeight: TextUnit = 1.6.em,
- linkInteractionListener: LinkInteractionListener? = null
+ linkInteractionListener: LinkInteractionListener? = null,
+ textAlign: TextAlign = TextAlign.Start
) {
Text(
modifier = modifier,
@@ -51,6 +53,7 @@ fun HtmlText(
color = color,
maxLines = maxLines,
overflow = overflow,
+ textAlign = textAlign
)
}
diff --git a/app/src/main/java/org/wikipedia/compose/components/LineChart.kt b/app/src/main/java/org/wikipedia/compose/components/LineChart.kt
new file mode 100644
index 00000000000..5d1b4f1b52e
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/compose/components/LineChart.kt
@@ -0,0 +1,119 @@
+package org.wikipedia.compose.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+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.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.compose.theme.WikipediaTheme
+import org.wikipedia.theme.Theme
+import kotlin.math.max
+
+@Composable
+fun LineChart(
+ map: Map,
+ modifier: Modifier = Modifier,
+ chartSampleSize: Int = 10,
+ strokeWidth: Dp = 2.dp,
+ strokeColor: Color = WikipediaTheme.colors.progressiveColor
+) {
+ if (map.isEmpty()) {
+ return
+ }
+
+ val sortedValues = map.entries.sortedBy { it.key }.map { it.value }
+
+ // Downsample the data to avoid the chart being too crowded in a small layout.
+ val downsampledList = if (sortedValues.size <= chartSampleSize) {
+ sortedValues
+ } else {
+ val chunkSize = sortedValues.size / chartSampleSize
+ sortedValues
+ .chunked(chunkSize)
+ .map { it.average().toInt() }
+ }
+
+ val maxValue = downsampledList.maxOrNull() ?: 1
+ val minValue = 0
+ val range = max(maxValue - minValue, 1)
+
+ Canvas(
+ modifier = modifier
+ ) {
+ val canvasWidth = size.width
+ val canvasHeight = size.height
+ val xScale = canvasWidth / (downsampledList.size - 1)
+ val yScale = canvasHeight / range
+
+ val path = Path()
+ val firstY = canvasHeight - ((downsampledList.first() - minValue) * yScale)
+ path.moveTo(0f, firstY)
+
+ var index = 0
+ downsampledList.forEach {
+ if (index == 0) {
+ index++ // Skip the first point since we already moved to it
+ return@forEach
+ }
+ val x = index++ * xScale
+ val y = canvasHeight - ((it - minValue) * yScale)
+ path.lineTo(x, y)
+ }
+
+ drawPath(
+ path = path,
+ color = strokeColor,
+ style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LineChartPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LineChart(
+ map = mapOf(
+ "2025-01-01" to 100,
+ "2025-01-02" to 122,
+ "2025-01-03" to 100,
+ "2025-01-04" to 103,
+ "2025-01-05" to 120,
+ "2025-01-06" to 121,
+ "2025-01-07" to 110,
+ "2025-01-08" to 153,
+ "2025-01-09" to 100,
+ "2025-01-10" to 150,
+ "2025-01-11" to 160,
+ "2025-01-12" to 170,
+ "2025-01-13" to 180,
+ "2025-01-14" to 140,
+ "2025-01-15" to 130,
+ "2025-01-16" to 106,
+ "2025-01-17" to 102,
+ "2025-01-18" to 103,
+ "2025-01-19" to 95,
+ "2025-01-20" to 76,
+ ),
+ modifier = Modifier.fillMaxSize(),
+ chartSampleSize = 10,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/compose/components/TinyBarChart.kt b/app/src/main/java/org/wikipedia/compose/components/TinyBarChart.kt
new file mode 100644
index 00000000000..be93bf42fba
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/compose/components/TinyBarChart.kt
@@ -0,0 +1,86 @@
+package org.wikipedia.compose.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.wikipedia.compose.theme.BaseTheme
+import org.wikipedia.theme.Theme
+import kotlin.math.max
+
+@Composable
+fun TinyBarChart(
+ values: List,
+ modifier: Modifier = Modifier,
+ minColor: Color = Color.Gray,
+ maxColor: Color = Color.Green
+) {
+ val maxValue = values.maxOrNull() ?: 1
+ val minValue = 0
+ val valueRange = max(maxValue - minValue, 1)
+
+ Canvas(
+ modifier = modifier
+ ) {
+ val canvasWidth = size.width
+ val canvasHeight = size.height
+ val spacing = 6.dp.toPx()
+ val totalSpacing = spacing * (values.size - 1)
+ val barWidth = (canvasWidth - totalSpacing) / values.size
+
+ values.forEachIndexed { index, value ->
+ val barHeight = max(if (maxValue > 0) {
+ (value.toFloat() / maxValue.toFloat()) * canvasHeight
+ } else {
+ 0f
+ }, 4.dp.toPx())
+
+ val colorProgress = if (valueRange > 0) {
+ (value - minValue).toFloat() / valueRange.toFloat()
+ } else {
+ 1f
+ }
+
+ val barColor = Color(
+ red = minColor.red + (maxColor.red - minColor.red) * colorProgress,
+ green = minColor.green + (maxColor.green - minColor.green) * colorProgress,
+ blue = minColor.blue + (maxColor.blue - minColor.blue) * colorProgress,
+ alpha = minColor.alpha + (maxColor.alpha - minColor.alpha) * colorProgress
+ )
+
+ // Draw the bar from bottom up
+ drawRoundRect(
+ color = barColor,
+ topLeft = Offset(x = index * (barWidth + spacing), y = canvasHeight - barHeight),
+ size = Size(width = barWidth, height = barHeight),
+ cornerRadius = CornerRadius(2.dp.toPx(), 2.dp.toPx())
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun TinyBarChartPreview() {
+ BaseTheme(
+ currentTheme = Theme.LIGHT
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ SearchEmptyView(
+ emptyTexTitle = "No languages found"
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/compose/components/error/ComposeWikiErrorView.kt b/app/src/main/java/org/wikipedia/compose/components/error/ComposeWikiErrorView.kt
index eb03ef14996..1c154d89471 100644
--- a/app/src/main/java/org/wikipedia/compose/components/error/ComposeWikiErrorView.kt
+++ b/app/src/main/java/org/wikipedia/compose/components/error/ComposeWikiErrorView.kt
@@ -51,8 +51,9 @@ fun WikiErrorView(
caught: Throwable?,
pageTitle: PageTitle? = null,
errorClickEvents: WikiErrorClickEvents? = null,
+ retryForGenericError: Boolean = false
) {
- val errorType = getErrorType(caught, pageTitle)
+ val errorType = getErrorType(caught, pageTitle, retryForGenericError)
val context = LocalContext.current
val errorMessage = when {
@@ -164,12 +165,13 @@ private fun getClickEventForErrorType(
is ComposeErrorType.Generic -> wikiErrorClickEvents?.backClickListener
is ComposeErrorType.LoggedOut -> wikiErrorClickEvents?.loginClickListener
is ComposeErrorType.Empty -> wikiErrorClickEvents?.nextClickListener
+ is ComposeErrorType.Retry,
is ComposeErrorType.Offline,
is ComposeErrorType.Timeout -> wikiErrorClickEvents?.retryClickListener
}
}
-private fun getErrorType(caught: Throwable?, pageTitle: PageTitle?): ComposeErrorType {
+private fun getErrorType(caught: Throwable?, pageTitle: PageTitle?, retryForGenericError: Boolean): ComposeErrorType {
caught?.let {
when {
is404(it) -> {
@@ -190,15 +192,15 @@ private fun getErrorType(caught: Throwable?, pageTitle: PageTitle?): ComposeErro
}
}
}
- return ComposeErrorType.Generic()
+ return if (retryForGenericError) ComposeErrorType.Retry() else ComposeErrorType.Generic()
}
sealed class ComposeErrorType(
- @DrawableRes val icon: Int,
- @StringRes val text: Int,
- @StringRes val buttonText: Int,
+ @param:DrawableRes val icon: Int,
+ @param:StringRes val text: Int,
+ @param:StringRes val buttonText: Int,
val hasFooterText: Boolean = false,
- @StringRes val footerText: Int = 0,
+ @param:StringRes val footerText: Int = 0,
) {
class UserPageMissing : ComposeErrorType(
icon = R.drawable.ic_userpage_error_icon,
@@ -241,6 +243,12 @@ sealed class ComposeErrorType(
text = R.string.error_message_generic,
buttonText = R.string.error_back
)
+
+ class Retry : ComposeErrorType(
+ icon = R.drawable.ic_error_black_24dp,
+ text = R.string.error_message_generic,
+ buttonText = R.string.offline_load_error_retry
+ )
}
data class WikiErrorClickEvents(
@@ -250,9 +258,9 @@ data class WikiErrorClickEvents(
var loginClickListener: (() -> Unit)? = null,
)
-@Preview
+@Preview(showBackground = true)
@Composable
-private fun WikiErrorViewPreview() {
+private fun WikiErrorViewGenericPreview() {
BaseTheme(
currentTheme = Theme.DARK
) {
@@ -261,3 +269,16 @@ private fun WikiErrorViewPreview() {
)
}
}
+
+@Preview(showBackground = true)
+@Composable
+private fun WikiErrorViewRetryPreview() {
+ BaseTheme(
+ currentTheme = Theme.DARK
+ ) {
+ WikiErrorView(
+ caught = Exception(),
+ retryForGenericError = true
+ )
+ }
+}
diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt
index 3cc8dc704e5..de72d024b9b 100644
--- a/app/src/main/java/org/wikipedia/dataclient/Service.kt
+++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt
@@ -438,7 +438,8 @@ interface Service {
@Query("uclimit") maxCount: Int,
@Query("ucnamespace") ns: String?,
@Query("ucshow") filter: String?,
- @Query("uccontinue") uccontinue: String?
+ @Query("uccontinue") uccontinue: String?,
+ @Query("ucdir") ucdir: String? = null
): MwQueryResponse
@GET(MW_API_PREFIX + "action=query&list=usercontribs&meta=userinfo&uiprop=editcount")
diff --git a/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt b/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt
index 81e2ca7a76c..ca1c65a1b36 100644
--- a/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt
+++ b/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt
@@ -1,11 +1,14 @@
package org.wikipedia.dataclient.growthtasks
+import android.text.format.DateUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import org.wikipedia.json.JsonUtil
+import org.wikipedia.page.PageTitle
+import java.time.LocalDate
@Suppress("unused")
@Serializable
@@ -40,6 +43,34 @@ class GrowthUserImpact(
val topViewedArticles: Map by lazy { if (mTopViewedArticles is JsonObject) { JsonUtil.json.decodeFromJsonElement(mTopViewedArticles) } else { emptyMap() } }
val longestEditingStreak: EditStreak? by lazy { if (mLongestEditingStreak is JsonObject) { JsonUtil.json.decodeFromJsonElement(mLongestEditingStreak) } else { null } }
+ val groupEditsByMonth: Map by lazy {
+ editCountByDay.entries.groupBy { it.key.substring(0, 7) }
+ .mapValues { it.value.sumOf { entry -> entry.value } }
+ }
+
+ var topViewedArticlesWithPageTitle: Map = emptyMap()
+
+ val editsThisMonth by lazy { groupEditsByMonth[LocalDate.now().toString().substring(0, 7)] ?: 0 }
+ val editsLastMonth by lazy { groupEditsByMonth[LocalDate.now().minusMonths(1).toString().substring(0, 7)] ?: 0 }
+
+ val lastThirtyDaysEdits by lazy {
+ val thirtyDaysAgo = LocalDate.now().minusDays(30)
+ // Some days do not have a key, fill them with 0
+ val filledEditCountByDay = (0..30).associate {
+ val date = thirtyDaysAgo.plusDays(it.toLong()).toString()
+ date to (editCountByDay[date] ?: 0)
+ }
+ filledEditCountByDay
+ }
+
+ val lastEditRelativeTime by lazy {
+ DateUtils.getRelativeTimeSpanString(
+ lastEditTimestamp * 1000,
+ System.currentTimeMillis(),
+ 0L
+ ).toString()
+ }
+
@Serializable
class ArticleViews(
val firstEditDate: String = "",
diff --git a/app/src/main/java/org/wikipedia/events/LoggedInEvent.kt b/app/src/main/java/org/wikipedia/events/LoggedInEvent.kt
new file mode 100644
index 00000000000..04f1b5b5b79
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/events/LoggedInEvent.kt
@@ -0,0 +1,3 @@
+package org.wikipedia.events
+
+class LoggedInEvent
diff --git a/app/src/main/java/org/wikipedia/extensions/Date.kt b/app/src/main/java/org/wikipedia/extensions/Date.kt
new file mode 100644
index 00000000000..32678b62b9d
--- /dev/null
+++ b/app/src/main/java/org/wikipedia/extensions/Date.kt
@@ -0,0 +1,17 @@
+package org.wikipedia.extensions
+
+import java.time.LocalDate
+import java.time.ZoneId
+import java.util.Date
+
+fun Date.toLocalDate(): LocalDate {
+ return this.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
+}
+
+fun Date.isToday(): Boolean {
+ return this.toLocalDate() == LocalDate.now()
+}
+
+fun Date.isYesterday(): Boolean {
+ return this.toLocalDate() == LocalDate.now().minusDays(1)
+}
diff --git a/app/src/main/java/org/wikipedia/games/db/DailyGameHistoryDao.kt b/app/src/main/java/org/wikipedia/games/db/DailyGameHistoryDao.kt
index af0f0e41913..7f1447bba9e 100644
--- a/app/src/main/java/org/wikipedia/games/db/DailyGameHistoryDao.kt
+++ b/app/src/main/java/org/wikipedia/games/db/DailyGameHistoryDao.kt
@@ -62,4 +62,30 @@ interface DailyGameHistoryDao {
return currentStreak
}
+
+ suspend fun getBestStreak(gameName: Int, language: String): Int {
+ val history = getGameHistory(gameName, language).filter { it.playType == PlayTypes.PLAYED_ON_SAME_DAY.ordinal }
+ if (history.isEmpty()) {
+ return 0
+ }
+
+ var bestStreak = 0
+ var currentStreak = 0
+ var expectedDate = LocalDate.now() // Start with today's date
+
+ for (record in history) {
+ val recordDate = LocalDate.of(record.year, Month.of(record.month), record.day)
+
+ if (recordDate == expectedDate) {
+ currentStreak++
+ expectedDate = expectedDate.minusDays(1) // Move to the previous day
+ } else if (recordDate.isBefore(expectedDate)) {
+ bestStreak = maxOf(bestStreak, currentStreak)
+ currentStreak = 0
+ expectedDate = recordDate.minusDays(1) // Reset to the day before the record date
+ }
+ }
+
+ return maxOf(bestStreak, currentStreak)
+ }
}
diff --git a/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameViewModel.kt b/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameViewModel.kt
index c33c9c9dc1b..fae912eda6c 100644
--- a/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameViewModel.kt
+++ b/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameViewModel.kt
@@ -165,14 +165,14 @@ class OnThisDayGameViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
currentQuestionState = composeQuestionState(0),
currentQuestionIndex = currentState.totalQuestions
)
- _gameState.postValue(GameEnded(currentState, getGameStatistics()))
+ _gameState.postValue(GameEnded(currentState, getGameStatistics(wikiSite.languageCode)))
} else if (currentState.currentQuestionState.month == currentMonth && currentState.currentQuestionState.day == currentDay && currentState.currentQuestionIndex == 0 && !currentState.currentQuestionState.goToNext) {
// we're just starting the current game.
_gameState.postValue(GameStarted(currentState))
} else if (currentState.currentQuestionState.month == currentMonth && currentState.currentQuestionState.day == currentDay &&
currentState.currentQuestionIndex >= currentState.totalQuestions) {
// we're already done for today.
- _gameState.postValue(GameEnded(currentState, getGameStatistics()))
+ _gameState.postValue(GameEnded(currentState, getGameStatistics(wikiSite.languageCode)))
} else if (currentState.currentQuestionState.month != currentMonth || currentState.currentQuestionState.day != currentDay) {
// the date in our current state doesn't match the requested date, so start a new game.
currentState = currentState.copy(currentQuestionState = composeQuestionState(0), currentQuestionIndex = 0, answerState = List(MAX_QUESTIONS) { false })
@@ -243,7 +243,7 @@ class OnThisDayGameViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
AppDatabase.instance.dailyGameHistoryDao().update(gameHistory)
}
}
- _gameState.postValue(GameEnded(currentState, getGameStatistics()))
+ _gameState.postValue(GameEnded(currentState, getGameStatistics(wikiSite.languageCode)))
} else {
currentState = currentState.copy(
currentQuestionState = composeQuestionState(nextQuestionIndex),
@@ -347,35 +347,6 @@ class OnThisDayGameViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
loadGameState(useDateFromState = false)
}
- private suspend fun getGameStatistics(): GameStatistics {
- return withContext(Dispatchers.IO) {
- val totalGamesPlayed = async {
- AppDatabase.instance.dailyGameHistoryDao().getTotalGamesPlayed(
- gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
- language = wikiSite.languageCode
- )
- }
- val averageScore = async {
- AppDatabase.instance.dailyGameHistoryDao().getAverageScore(
- gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
- language = wikiSite.languageCode
- )
- }
- val currentStreak = async {
- AppDatabase.instance.dailyGameHistoryDao().getCurrentStreak(
- gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
- language = wikiSite.languageCode
- )
- }
-
- GameStatistics(
- totalGamesPlayed.await(),
- averageScore.await(),
- currentStreak.await()
- )
- }
- }
-
// TODO: remove this in May, 2026
private suspend fun migrateGameHistoryFromPrefsToDatabase() {
if (Prefs.otdGameHistory.isEmpty()) {
@@ -440,7 +411,8 @@ class OnThisDayGameViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
data class GameStatistics(
val totalGamesPlayed: Int = 0,
val averageScore: Double? = null,
- val currentStreak: Int = 0
+ val currentStreak: Int = 0,
+ val bestStreak: Int = 0
)
@Serializable
@@ -474,5 +446,42 @@ class OnThisDayGameViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
fun dateReleasedForLang(lang: String): LocalDate {
return if (lang == "de" || ReleaseUtil.isPreBetaRelease) LocalDate.of(2025, 2, 20) else LocalDate.of(2025, 5, 21)
}
+
+ suspend fun getGameStatistics(languageCode: String): GameStatistics {
+ return withContext(Dispatchers.IO) {
+ val totalGamesPlayed = async {
+ AppDatabase.instance.dailyGameHistoryDao().getTotalGamesPlayed(
+ gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
+ language = languageCode
+ )
+ }
+ val averageScore = async {
+ AppDatabase.instance.dailyGameHistoryDao().getAverageScore(
+ gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
+ language = languageCode
+ )
+ }
+ val currentStreak = async {
+ AppDatabase.instance.dailyGameHistoryDao().getCurrentStreak(
+ gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
+ language = languageCode
+ )
+ }
+
+ val bestStreak = async {
+ AppDatabase.instance.dailyGameHistoryDao().getBestStreak(
+ gameName = WikiGames.WHICH_CAME_FIRST.ordinal,
+ language = languageCode
+ )
+ }
+
+ GameStatistics(
+ totalGamesPlayed = totalGamesPlayed.await(),
+ averageScore = averageScore.await(),
+ currentStreak = currentStreak.await(),
+ bestStreak = bestStreak.await()
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt
index e68b664df97..6be73b7668d 100644
--- a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt
+++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt
@@ -102,5 +102,6 @@ class HistoryEntry(
const val SOURCE_FEED_PLACES = 42
const val SOURCE_ON_THIS_DAY_GAME = 43
const val SOURCE_RECOMMENDED_READING_LIST = 44
+ const val SOURCE_ACTIVITY_TAB = 45
}
}
diff --git a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt
index a49bcbb9985..4939e3aaf01 100644
--- a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt
+++ b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt
@@ -23,11 +23,15 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import org.wikipedia.BackPressedHandler
import org.wikipedia.Constants
import org.wikipedia.R
import org.wikipedia.WikipediaApp
import org.wikipedia.activity.FragmentUtil
+import org.wikipedia.database.AppDatabase
import org.wikipedia.databinding.FragmentHistoryBinding
import org.wikipedia.main.MainActivity
import org.wikipedia.main.MainFragment
@@ -273,11 +277,9 @@ class HistoryFragment : Fragment(), BackPressedHandler {
}
clearHistoryButton.setOnClickListener {
if (selectedEntries.isEmpty()) {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.dialog_title_clear_history)
- .setMessage(R.string.dialog_message_clear_history)
- .setPositiveButton(R.string.dialog_message_clear_history_yes) { _, _ -> viewModel.deleteAllHistoryItems() }
- .setNegativeButton(R.string.dialog_message_clear_history_no, null).show()
+ clearAllHistory(requireContext(), lifecycleScope) {
+ viewModel.afterDeleteAllHistoryItems()
+ }
} else {
deleteSelectedPages()
}
@@ -445,6 +447,23 @@ class HistoryFragment : Fragment(), BackPressedHandler {
private const val VIEW_TYPE_HEADER = 1
private const val VIEW_TYPE_ITEM = 2
+ fun clearAllHistory(context: Context, coroutineScope: CoroutineScope, action: () -> Unit) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.dialog_title_clear_history)
+ .setMessage(R.string.dialog_message_clear_history)
+ .setPositiveButton(R.string.dialog_message_clear_history_yes) { _, _ ->
+ coroutineScope.launch(
+ CoroutineExceptionHandler { _, t -> L.e(t) }
+ ) {
+ AppDatabase.instance.historyEntryDao().deleteAll()
+ AppDatabase.instance.pageImagesDao().deleteAll()
+ AppDatabase.instance.categoryDao().deleteAll()
+ action()
+ }
+ }
+ .setNegativeButton(R.string.dialog_message_clear_history_no, null).show()
+ }
+
fun newInstance(): HistoryFragment {
return HistoryFragment()
}
diff --git a/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt b/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt
index 9111863d54f..85669bd5a54 100644
--- a/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt
+++ b/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt
@@ -51,11 +51,8 @@ class HistoryViewModel : ViewModel() {
}
}
- fun deleteAllHistoryItems() {
+ fun afterDeleteAllHistoryItems() {
viewModelScope.launch(handler) {
- AppDatabase.instance.historyEntryDao().deleteAll()
- AppDatabase.instance.pageImagesDao().deleteAll()
- AppDatabase.instance.categoryDao().deleteAll()
historyItems.postValue(Resource.Success(emptyList()))
}
}
diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt
index 8fe66f1d01b..ba64a923412 100644
--- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt
+++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt
@@ -24,8 +24,8 @@ interface HistoryEntryDao {
@Query("SELECT * FROM HistoryEntry WHERE authority = :authority AND lang = :lang AND apiTitle = :apiTitle AND timestamp = :timestamp LIMIT 1")
suspend fun findEntryBy(authority: String, lang: String, apiTitle: String, timestamp: Long): HistoryEntry?
- @Query("SELECT COUNT(*) FROM HistoryEntry WHERE timestamp BETWEEN :startDate AND :endDate ")
- suspend fun getHistoryCount(startDate: Long?, endDate: Long?): Int
+ @Query("SELECT COUNT(*) FROM (SELECT DISTINCT HistoryEntry.lang, HistoryEntry.apiTitle FROM HistoryEntry WHERE timestamp BETWEEN :startDate AND :endDate)")
+ suspend fun getDistinctEntriesBetween(startDate: Long?, endDate: Long?): Int
@Query("SELECT DISTINCT displayTitle FROM HistoryEntry LIMIT 3")
suspend fun getDisplayTitles(): List
@@ -39,6 +39,12 @@ interface HistoryEntryDao {
@Query("DELETE FROM HistoryEntry WHERE authority = :authority AND lang = :lang AND namespace = :namespace AND apiTitle = :apiTitle")
suspend fun deleteBy(authority: String, lang: String, namespace: String?, apiTitle: String)
+ @Query("SELECT COUNT(*) FROM (SELECT DISTINCT HistoryEntry.lang, HistoryEntry.apiTitle FROM HistoryEntry WHERE timestamp > :timestamp)")
+ suspend fun getDistinctEntriesSince(timestamp: Long): Int?
+
+ @Query("SELECT * FROM HistoryEntry ORDER BY timestamp DESC LIMIT 1")
+ suspend fun getMostRecentEntry(): HistoryEntry?
+
@Transaction
suspend fun insert(entries: List) {
entries.forEach {
diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImage.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImage.kt
index a690b1d0aee..615a36472da 100644
--- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImage.kt
+++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImage.kt
@@ -14,5 +14,6 @@ data class HistoryEntryWithImage(
val imageName: String?,
val description: String?,
val geoLat: Double?,
- val geoLon: Double?
+ val geoLon: Double?,
+ val id: Long,
)
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 7ca5fba6284..840fed510f0 100644
--- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt
+++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt
@@ -17,10 +17,15 @@ import java.util.TimeZone
@Dao
interface HistoryEntryWithImageDao {
+ @Query("SELECT HistoryEntry.*, PageImage.imageName, PageImage.description, PageImage.geoLat, PageImage.geoLon, PageImage.timeSpentSec FROM HistoryEntry LEFT OUTER JOIN PageImage ON (HistoryEntry.namespace = PageImage.namespace AND HistoryEntry.apiTitle = PageImage.apiTitle AND HistoryEntry.lang = PageImage.lang) INNER JOIN (SELECT lang, apiTitle, MAX(timestamp) as max_timestamp FROM HistoryEntry GROUP BY lang, apiTitle) LatestEntries ON HistoryEntry.apiTitle = LatestEntries.apiTitle AND HistoryEntry.timestamp = LatestEntries.max_timestamp ORDER BY timestamp DESC LIMIT :limit OFFSET :offset")
+ suspend fun getHistoryEntriesWithOffset(
+ limit: Int,
+ offset: Int
+ ): List
// TODO: convert to PagingSource.
// https://developer.android.com/topic/libraries/architecture/paging/v3-overview
- @Query("SELECT HistoryEntry.*, PageImage.imageName, PageImage.description, PageImage.geoLat, PageImage.geoLon, PageImage.timeSpentSec FROM HistoryEntry LEFT OUTER JOIN PageImage ON (HistoryEntry.namespace = PageImage.namespace AND HistoryEntry.apiTitle = PageImage.apiTitle AND HistoryEntry.lang = PageImage.lang) INNER JOIN(SELECT lang, apiTitle, MAX(timestamp) as max_timestamp FROM HistoryEntry GROUP BY lang, apiTitle) LatestEntries ON HistoryEntry.apiTitle = LatestEntries.apiTitle AND HistoryEntry.timestamp = LatestEntries.max_timestamp WHERE UPPER(HistoryEntry.displayTitle) LIKE UPPER(:term) ESCAPE '\\' ORDER BY timestamp DESC")
+ @Query("SELECT HistoryEntry.*, PageImage.imageName, PageImage.description, PageImage.geoLat, PageImage.geoLon, PageImage.timeSpentSec FROM HistoryEntry LEFT OUTER JOIN PageImage ON (HistoryEntry.namespace = PageImage.namespace AND HistoryEntry.apiTitle = PageImage.apiTitle AND HistoryEntry.lang = PageImage.lang) INNER JOIN (SELECT lang, apiTitle, MAX(timestamp) as max_timestamp FROM HistoryEntry GROUP BY lang, apiTitle) LatestEntries ON HistoryEntry.apiTitle = LatestEntries.apiTitle AND HistoryEntry.timestamp = LatestEntries.max_timestamp WHERE UPPER(HistoryEntry.displayTitle) LIKE UPPER(:term) ESCAPE '\\' ORDER BY timestamp DESC")
@RewriteQueriesToDropUnusedColumns
suspend fun findEntriesBySearchTerm(term: String): List
@@ -29,6 +34,13 @@ interface HistoryEntryWithImageDao {
@RewriteQueriesToDropUnusedColumns
suspend fun findEntriesBy(excludeSource1: Int, excludeSource2: Int, excludeSource3: Int, minTimeSpent: Int, limit: Int): List
+ @Query("SELECT SUM(timeSpentSec) FROM (" +
+ " SELECT DISTINCT HistoryEntry.lang, HistoryEntry.apiTitle, PageImage.timeSpentSec FROM HistoryEntry" +
+ " LEFT OUTER JOIN PageImage ON (HistoryEntry.namespace = PageImage.namespace AND HistoryEntry.apiTitle = PageImage.apiTitle AND HistoryEntry.lang = PageImage.lang)" +
+ " WHERE timestamp > :timeStamp" +
+ ")")
+ suspend fun getTimeSpentSinceTimeStamp(timeStamp: Long): Long
+
suspend fun findHistoryItem(wikiSite: WikiSite, searchQuery: String): SearchResults {
var normalizedQuery = StringUtils.stripAccents(searchQuery)
if (normalizedQuery.isEmpty()) {
@@ -77,6 +89,12 @@ interface HistoryEntryWithImageDao {
return entries.map { toHistoryEntry(it) }
}
+ suspend fun getHistoryItemWIthImage(searchTerm: String): List {
+ val normalizedQuery = StringUtils.stripAccents(searchTerm).replace("\\", "\\\\")
+ .replace("%", "\\%").replace("_", "\\_")
+ return findEntriesBySearchTerm("%$normalizedQuery%")
+ }
+
private fun normalizedQuery(searchQuery: String): String {
return StringUtils.stripAccents(searchQuery).lowercase(Locale.getDefault())
.replace("\\", "\\\\")
@@ -90,7 +108,7 @@ interface HistoryEntryWithImageDao {
lang = entryWithImage.lang,
apiTitle = entryWithImage.apiTitle,
displayTitle = entryWithImage.displayTitle,
- id = 0,
+ id = entryWithImage.id,
namespace = entryWithImage.namespace,
timestamp = entryWithImage.timestamp,
source = entryWithImage.source
diff --git a/app/src/main/java/org/wikipedia/login/LoginActivity.kt b/app/src/main/java/org/wikipedia/login/LoginActivity.kt
index 0322d657107..fd75def32f1 100644
--- a/app/src/main/java/org/wikipedia/login/LoginActivity.kt
+++ b/app/src/main/java/org/wikipedia/login/LoginActivity.kt
@@ -19,8 +19,10 @@ import org.wikipedia.auth.AccountUtil
import org.wikipedia.auth.AccountUtil.updateAccount
import org.wikipedia.captcha.CaptchaHandler
import org.wikipedia.captcha.CaptchaResult
+import org.wikipedia.concurrency.FlowEventBus
import org.wikipedia.createaccount.CreateAccountActivity
import org.wikipedia.databinding.ActivityLoginBinding
+import org.wikipedia.events.LoggedInEvent
import org.wikipedia.extensions.parcelableExtra
import org.wikipedia.notifications.PollNotificationWorker
import org.wikipedia.page.PageTitle
@@ -194,6 +196,7 @@ class LoginActivity : BaseActivity() {
PollNotificationWorker.schedulePollNotificationJob(this)
Prefs.isPushNotificationOptionsSet = false
updateSubscription()
+ FlowEventBus.post(LoggedInEvent())
finish()
}
@@ -294,6 +297,7 @@ class LoginActivity : BaseActivity() {
const val SOURCE_LOGOUT_BACKGROUND = "logout_background"
const val SOURCE_SUGGESTED_EDITS = "suggestededits"
const val SOURCE_TALK = "talk"
+ const val SOURCE_ACTIVITY = "activity"
fun newIntent(context: Context, source: String, createAccountFirst: Boolean = true): Intent {
return Intent(context, LoginActivity::class.java)
diff --git a/app/src/main/java/org/wikipedia/main/MainFragment.kt b/app/src/main/java/org/wikipedia/main/MainFragment.kt
index a1f89582de0..90fcbc36fd5 100644
--- a/app/src/main/java/org/wikipedia/main/MainFragment.kt
+++ b/app/src/main/java/org/wikipedia/main/MainFragment.kt
@@ -21,6 +21,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.core.view.descendants
+import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
@@ -37,6 +38,9 @@ import org.wikipedia.R
import org.wikipedia.WikipediaApp
import org.wikipedia.activity.BaseActivity
import org.wikipedia.activity.FragmentUtil.getCallback
+import org.wikipedia.activitytab.ActivityTabABTest
+import org.wikipedia.activitytab.ActivityTabFragment
+import org.wikipedia.activitytab.ActivityTabOnboardingActivity
import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper
import org.wikipedia.auth.AccountUtil
import org.wikipedia.commons.FilePageActivity
@@ -91,7 +95,7 @@ import org.wikipedia.yearinreview.YearInReviewEntryDialog
import java.io.File
import java.util.concurrent.TimeUnit
-class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.Callback, HistoryFragment.Callback, MenuNavTabDialog.Callback {
+class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.Callback, HistoryFragment.Callback, MenuNavTabDialog.Callback, ActivityTabFragment.Callback {
interface Callback {
fun onTabChanged(tab: NavTab)
fun updateToolbarElevation(elevate: Boolean)
@@ -121,6 +125,12 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.
}
}
+ private val activityTabOnboardingLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ goToTab(NavTab.EDITS)
+ }
+ }
+
val currentFragment get() = (binding.mainViewPager.adapter as NavTabFragmentPagerAdapter).getFragmentAt(binding.mainViewPager.currentItem)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -155,6 +165,12 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.
val shouldShowRedDotForRecommendedReadingList = (!Prefs.isRecommendedReadingListOnboardingShown) || (Prefs.isRecommendedReadingListEnabled && Prefs.isNewRecommendedReadingListGenerated)
binding.mainNavTabLayout.setOverlayDot(NavTab.READING_LISTS, shouldShowRedDotForRecommendedReadingList)
binding.mainNavTabLayout.setOnItemSelectedListener { item ->
+ if (item.order == NavTab.EDITS.code()) {
+ if (ActivityTabABTest().isInTestGroup() && !Prefs.isActivityTabOnboardingShown) {
+ activityTabOnboardingLauncher.launch(ActivityTabOnboardingActivity.newIntent(requireContext()))
+ return@setOnItemSelectedListener false
+ }
+ }
if (item.order == NavTab.MORE.code()) {
ExclusiveBottomSheetPresenter.show(childFragmentManager, MenuNavTabDialog.newInstance())
return@setOnItemSelectedListener false
@@ -172,6 +188,8 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.
true
}
+ binding.mainNavTabLayout.setOverlayDot(NavTab.EDITS, ActivityTabABTest().isInTestGroup() && !Prefs.activityTabRedDotShown)
+
notificationButtonView = NotificationButtonView(requireActivity())
maybeShowEditsTooltip()
@@ -535,7 +553,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.
}
private fun goToTab(tab: NavTab) {
- binding.mainNavTabLayout.selectedItemId = binding.mainNavTabLayout.menu.getItem(tab.code()).itemId
+ binding.mainNavTabLayout.selectedItemId = binding.mainNavTabLayout.menu[tab.code()].itemId
}
private fun refreshContents() {
@@ -609,6 +627,10 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment.
return getCallback(this, Callback::class.java)
}
+ override fun onNavigateTo(navTab: NavTab) {
+ goToTab(navTab)
+ }
+
companion object {
// Actually shows on the 4th time of using the app. The Pref.incrementExploreFeedVisitCount() gets call after MainFragment.onResume()
private const val SHOW_EDITS_SNACKBAR_COUNT = 3
diff --git a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt
index 9d7f361aaf2..10cda28e7af 100644
--- a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt
+++ b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt
@@ -9,6 +9,7 @@ import androidx.core.widget.ImageViewCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.wikipedia.R
import org.wikipedia.activity.FragmentUtil
+import org.wikipedia.activitytab.ActivityTabABTest
import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent
import org.wikipedia.analytics.eventplatform.DonorExperienceEvent
import org.wikipedia.analytics.eventplatform.PlacesEvent
@@ -17,6 +18,7 @@ import org.wikipedia.databinding.ViewMainDrawerBinding
import org.wikipedia.page.ExtendedBottomSheetDialogFragment
import org.wikipedia.places.PlacesActivity
import org.wikipedia.settings.Prefs
+import org.wikipedia.suggestededits.SuggestedEditsTasksActivity
import org.wikipedia.util.DimenUtil
import org.wikipedia.util.ResourceUtil.getThemedColorStateList
@@ -92,6 +94,12 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() {
dismiss()
}
+ binding.mainDrawerEditContainer.setOnClickListener {
+ BreadCrumbLogEvent.logClick(requireActivity(), binding.mainDrawerEditContainer)
+ startActivity(SuggestedEditsTasksActivity.newIntent(requireContext()))
+ dismiss()
+ }
+
updateState()
return binding.root
}
@@ -140,6 +148,7 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() {
binding.mainDrawerWatchlistContainer.visibility = View.GONE
binding.mainDrawerContribsContainer.visibility = View.GONE
}
+ binding.mainDrawerEditContainer.isVisible = ActivityTabABTest().isInTestGroup()
}
private fun callback(): Callback? {
diff --git a/app/src/main/java/org/wikipedia/navtab/NavTab.kt b/app/src/main/java/org/wikipedia/navtab/NavTab.kt
index 81a4875a89d..6f008bf4bd3 100644
--- a/app/src/main/java/org/wikipedia/navtab/NavTab.kt
+++ b/app/src/main/java/org/wikipedia/navtab/NavTab.kt
@@ -4,6 +4,8 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import org.wikipedia.R
+import org.wikipedia.activitytab.ActivityTabABTest
+import org.wikipedia.activitytab.ActivityTabFragment
import org.wikipedia.feed.FeedFragment
import org.wikipedia.history.HistoryFragment
import org.wikipedia.model.EnumCode
@@ -32,10 +34,12 @@ enum class NavTab constructor(
}
},
EDITS(
- R.string.nav_item_suggested_edits, R.id.nav_tab_edits, R.drawable.selector_nav_edits
+ if (ActivityTabABTest().isInTestGroup()) R.string.nav_item_activity else R.string.nav_item_suggested_edits,
+ R.id.nav_tab_edits,
+ if (ActivityTabABTest().isInTestGroup()) R.drawable.selector_nav_activity else R.drawable.selector_nav_edits
) {
override fun newInstance(): Fragment {
- return SuggestedEditsTasksFragment.newInstance()
+ return if (ActivityTabABTest().isInTestGroup()) ActivityTabFragment.newInstance() else SuggestedEditsTasksFragment.newInstance()
}
},
MORE(R.string.nav_item_more, R.id.nav_tab_more, R.drawable.ic_menu_white_24dp) {
diff --git a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt
index f3a15661222..35b896922d5 100644
--- a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt
+++ b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt
@@ -86,6 +86,18 @@ interface ReadingListPageDao {
@Query("SELECT * FROM ReadingListPage WHERE remoteId < 1")
suspend fun getAllPagesToBeSynced(): List
+ @Query("SELECT COUNT(*) FROM ReadingListPage WHERE mtime > :timestamp")
+ suspend fun getTotalPagesSince(timestamp: Long): Int?
+
+ @Query("SELECT * FROM ReadingListPage WHERE mtime > :timestamp ORDER BY mtime DESC LIMIT :limit")
+ suspend fun getPagesSince(timestamp: Long, limit: Int): List
+
+ @Query("SELECT * FROM ReadingListPage ORDER BY mtime DESC LIMIT 1")
+ suspend fun getMostRecentSavedPage(): ReadingListPage?
+
+ @Query("SELECT * FROM ReadingListPage ORDER BY mtime DESC LIMIT :limit OFFSET :offset")
+ suspend fun getPagesWithLimitOffset(limit: Int, offset: Int): List
+
suspend fun getAllPagesToBeSaved() = getPagesByStatus(ReadingListPage.STATUS_QUEUE_FOR_SAVE, true)
suspend fun getAllPagesToBeForcedSave() = getPagesByStatus(ReadingListPage.STATUS_QUEUE_FOR_FORCED_SAVE, true)
diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt
index 072891986c8..53e247b0b7e 100644
--- a/app/src/main/java/org/wikipedia/settings/Prefs.kt
+++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt
@@ -7,6 +7,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import org.wikipedia.BuildConfig
import org.wikipedia.R
import org.wikipedia.WikipediaApp
+import org.wikipedia.activitytab.ActivityTabModules
import org.wikipedia.analytics.SessionData
import org.wikipedia.analytics.eventplatform.AppSessionEvent
import org.wikipedia.analytics.eventplatform.StreamConfig
@@ -830,9 +831,31 @@ object Prefs {
get() = PrefsIoUtil.getBoolean(R.string.preference_key_recommended_reading_list_reset, false)
set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_recommended_reading_list_reset, value)
+ var activityTabRedDotShown
+ get() = PrefsIoUtil.getBoolean(R.string.preference_key_activity_tab_red_dot_shown, false)
+ set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_activity_tab_red_dot_shown, value)
+
+ var impactLastQueryTime
+ get() = PrefsIoUtil.getLong(R.string.preference_key_impact_last_query_time, 0)
+ set(value) = PrefsIoUtil.setLong(R.string.preference_key_impact_last_query_time, value)
+
+ var impactLastResponseBody
+ get() = JsonUtil.decodeFromString