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>(PrefsIoUtil.getString(R.string.preference_key_impact_last_response_body, null)) + ?: emptyMap() + set(langSupportedMap) = PrefsIoUtil.setString(R.string.preference_key_impact_last_response_body, JsonUtil.encodeToString(langSupportedMap)) + var donationReminderConfig get() = JsonUtil.decodeFromString( PrefsIoUtil.getString(R.string.preference_key_donation_reminder_config, null) ) ?: DonationReminderConfig() set(types) = PrefsIoUtil.setString(R.string.preference_key_donation_reminder_config, JsonUtil.encodeToString(types)) + + var activityTabModules: ActivityTabModules + get() = JsonUtil.decodeFromString(PrefsIoUtil.getString(R.string.preference_key_activity_tab_modules, null)) + ?: ActivityTabModules() + set(modules) = PrefsIoUtil.setString(R.string.preference_key_activity_tab_modules, JsonUtil.encodeToString(modules)) + + var isActivityTabOnboardingShown + get() = PrefsIoUtil.getBoolean(R.string.preference_key_activity_tab_onboarding_shown, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_activity_tab_onboarding_shown, value) } diff --git a/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksActivity.kt b/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksActivity.kt new file mode 100644 index 00000000000..dd262b247f7 --- /dev/null +++ b/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksActivity.kt @@ -0,0 +1,22 @@ +package org.wikipedia.suggestededits + +import android.content.Context +import android.content.Intent +import org.wikipedia.activity.SingleFragmentActivity + +class SuggestedEditsTasksActivity : SingleFragmentActivity() { + + override fun onUnreadNotification() { + fragment.updateNotificationDot(true) + } + + override fun createFragment(): SuggestedEditsTasksFragment { + return SuggestedEditsTasksFragment.newInstance() + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, SuggestedEditsTasksActivity::class.java) + } + } +} diff --git a/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksFragment.kt b/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksFragment.kt index 1781bdfd1b1..fb0c2911358 100644 --- a/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksFragment.kt +++ b/app/src/main/java/org/wikipedia/suggestededits/SuggestedEditsTasksFragment.kt @@ -1,12 +1,16 @@ package org.wikipedia.suggestededits import android.app.Activity -import android.net.Uri 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.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.fragment.app.Fragment @@ -20,6 +24,7 @@ import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activitytab.ActivityTabABTest import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent @@ -38,6 +43,7 @@ import org.wikipedia.language.AppLanguageLookUpTable import org.wikipedia.login.LoginActivity import org.wikipedia.main.MainActivity import org.wikipedia.navtab.NavTab +import org.wikipedia.notifications.NotificationActivity import org.wikipedia.settings.Prefs import org.wikipedia.settings.languages.WikipediaLanguagesActivity import org.wikipedia.usercontrib.UserContribListActivity @@ -50,10 +56,11 @@ import org.wikipedia.util.ResourceUtil import org.wikipedia.util.UriUtil import org.wikipedia.views.DefaultRecyclerAdapter import org.wikipedia.views.DefaultViewHolder +import org.wikipedia.views.NotificationButtonView import java.time.LocalDateTime import java.time.ZoneId -class SuggestedEditsTasksFragment : Fragment() { +class SuggestedEditsTasksFragment : Fragment(), MenuProvider { private var _binding: FragmentSuggestedEditsTasksBinding? = null private val binding get() = _binding!! @@ -65,9 +72,13 @@ class SuggestedEditsTasksFragment : Fragment() { private lateinit var imageRecommendationsTask: SuggestedEditsTask private lateinit var vandalismPatrolTask: SuggestedEditsTask + private var notificationButtonView: NotificationButtonView? = null + private val displayedTasks = ArrayList() private val callback = TaskViewCallback() + private val inActivityAbTestGroup = ActivityTabABTest().isInTestGroup() + private val sequentialTooltipRunnable = Runnable { if (!isAdded) { return@Runnable @@ -108,6 +119,11 @@ class SuggestedEditsTasksFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupTestingButtons() + if (inActivityAbTestGroup) { + notificationButtonView = NotificationButtonView(requireContext()) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + binding.layoutContributionsContainer.contributionsContainer.setOnClickListener { startActivity(UserContribListActivity.newIntent(requireActivity(), AccountUtil.userName)) } @@ -127,6 +143,9 @@ class SuggestedEditsTasksFragment : Fragment() { } binding.suggestedEditsScrollView.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, _ -> + if (inActivityAbTestGroup) { + return@OnScrollChangeListener + } (requireActivity() as MainActivity).updateToolbarElevation(scrollY > 0) }) tasksContainer.tasksRecyclerView.layoutManager = LinearLayoutManager(context) @@ -148,8 +167,7 @@ class SuggestedEditsTasksFragment : Fragment() { launch { FlowEventBus.events.collectLatest { event -> - if (event is LoggedOutEvent && - (requireActivity() as MainActivity).isCurrentFragmentSelected(this@SuggestedEditsTasksFragment)) { + if (event is LoggedOutEvent) { refreshContents() } } @@ -159,7 +177,9 @@ class SuggestedEditsTasksFragment : Fragment() { } fun refreshContents() { - (requireActivity() as MainActivity).onTabChanged(NavTab.EDITS) + if (!inActivityAbTestGroup) { + (requireActivity() as MainActivity).onTabChanged(NavTab.EDITS) + } requireActivity().invalidateOptionsMenu() viewModel.fetchData() } @@ -169,6 +189,50 @@ class SuggestedEditsTasksFragment : Fragment() { refreshContents() } + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_suggested_edits_tasks, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return false + } + + override fun onPrepareMenu(menu: Menu) { + notificationButtonView?.let { + val notificationMenuItem = menu.findItem(R.id.menu_notifications) + if (AccountUtil.isLoggedIn) { + notificationMenuItem.isVisible = true + it.setUnreadCount(Prefs.notificationUnreadCount) + it.setOnClickListener { + if (AccountUtil.isLoggedIn) { + startActivity(NotificationActivity.newIntent(requireActivity())) + } + } + it.contentDescription = + getString(R.string.notifications_activity_title) + notificationMenuItem.actionView = it + notificationMenuItem.expandActionView() + FeedbackUtil.setButtonTooltip(it) + } else { + notificationMenuItem.isVisible = false + } + updateNotificationDot(false) + } + } + + fun updateNotificationDot(animate: Boolean) { + notificationButtonView?.let { + if (AccountUtil.isLoggedIn && Prefs.notificationUnreadCount > 0) { + it.setUnreadCount(Prefs.notificationUnreadCount) + if (animate) { + it.runAnimation() + } + } else { + it.setUnreadCount(0) + } + } + } + override fun onDestroyView() { binding.layoutTasksContainer.tasksRecyclerView.adapter = null binding.suggestedEditsScrollView.removeCallbacks(sequentialTooltipRunnable) @@ -262,6 +326,13 @@ class SuggestedEditsTasksFragment : Fragment() { private fun setUserStatsViewsAndTooltips() { val contributionContainer = binding.layoutContributionsContainer + + if (inActivityAbTestGroup) { + contributionContainer.root.isVisible = false + binding.layoutTasksContainer.contributeSubtitleView.isVisible = false + return + } + contributionContainer.editsCountStatsView.setImageDrawable(R.drawable.ic_mode_edit_white_24dp) contributionContainer.editsCountStatsView.tooltipText = getString(R.string.suggested_edits_contributions_stat_tooltip) @@ -279,6 +350,9 @@ class SuggestedEditsTasksFragment : Fragment() { } private fun showOneTimeSequentialUserStatsTooltips() { + if (inActivityAbTestGroup) { + return + } binding.suggestedEditsScrollView.fullScroll(View.FOCUS_UP) binding.suggestedEditsScrollView.removeCallbacks(sequentialTooltipRunnable) binding.suggestedEditsScrollView.postDelayed(sequentialTooltipRunnable, 500) @@ -301,7 +375,7 @@ class SuggestedEditsTasksFragment : Fragment() { clearContents() binding.messageCard.setDisabled(getString(R.string.suggested_edits_gate_message, AccountUtil.userName)) binding.messageCard.setPositiveButton(R.string.suggested_edits_learn_more, { - UriUtil.visitInExternalBrowser(requireContext(), Uri.parse(MIN_CONTRIBUTIONS_GATE_URL)) + UriUtil.visitInExternalBrowser(requireContext(), MIN_CONTRIBUTIONS_GATE_URL.toUri()) }, true) binding.messageCard.isVisible = true return true diff --git a/app/src/main/java/org/wikipedia/util/DateUtil.kt b/app/src/main/java/org/wikipedia/util/DateUtil.kt index cb32ceb6d51..85028ef2fee 100644 --- a/app/src/main/java/org/wikipedia/util/DateUtil.kt +++ b/app/src/main/java/org/wikipedia/util/DateUtil.kt @@ -8,6 +8,7 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.extensions.getResources import org.wikipedia.extensions.getString +import org.wikipedia.extensions.toLocalDate import org.wikipedia.feed.model.UtcDate import java.text.SimpleDateFormat import java.time.Instant @@ -18,6 +19,8 @@ import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAccessor import java.util.Calendar import java.util.Date @@ -63,6 +66,14 @@ object DateUtil { return getDateStringWithSkeletonPattern(date, "MMMM d") } + fun getMonthOnlyDateStringFromTimeString(dateStr: String): String { + return getMonthOnlyDateString(SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(dateStr)!!) + } + + fun getMonthOnlyDateString(date: LocalDate): String { + return getDateStringWithSkeletonPattern(date, "MMMM d") + } + fun getMonthOnlyWithoutDayDateString(date: Date): String { return getDateStringWithSkeletonPattern(date, "MMMM") } @@ -75,8 +86,8 @@ object DateUtil { return getCachedDateFormat("yyyyMMdd", Locale.ROOT, true).format(date) } - fun getMMMMdYYYY(date: Date): String { - return getCachedDateFormat("MMMM d, yyyy", Locale.getDefault(), true).format(date) + fun getMMMMdYYYY(date: Date, utc: Boolean = true): String { + return getCachedDateFormat("MMMM d, yyyy", Locale.getDefault(), utc).format(date) } private fun getExtraShortDateString(date: Date): String { @@ -203,4 +214,25 @@ object DateUtil { val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), zoneId) return zonedDateTime.year } + + fun toRelativeDateString(date: Date): String { + val localDate = date.toLocalDate() + val now = LocalDate.now() + val daysDiff = ChronoUnit.DAYS.between(localDate, now) + + return when { + daysDiff < 7 -> { + val dayOfWeek = localDate.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault()) + dayOfWeek + } + + localDate.year == now.year -> { + DateUtil.getMonthOnlyDateString(localDate) + } + + else -> { + DateUtil.getShortDateString(localDate) + } + } + } } diff --git a/app/src/main/java/org/wikipedia/yearinreview/YearInReviewViewModel.kt b/app/src/main/java/org/wikipedia/yearinreview/YearInReviewViewModel.kt index 106a7c42602..cb9efb6d152 100644 --- a/app/src/main/java/org/wikipedia/yearinreview/YearInReviewViewModel.kt +++ b/app/src/main/java/org/wikipedia/yearinreview/YearInReviewViewModel.kt @@ -47,7 +47,7 @@ class YearInReviewViewModel() : ViewModel() { _uiScreenListState.value = Resource.Loading() val readCountJob = async { - personalizedStatistics.readCount = AppDatabase.instance.historyEntryDao().getHistoryCount(startTimeInMillis, endTimeInMillis) + personalizedStatistics.readCount = AppDatabase.instance.historyEntryDao().getDistinctEntriesBetween(startTimeInMillis, endTimeInMillis) if (personalizedStatistics.readCount >= MINIMUM_READ_COUNT) { personalizedStatistics.readCountApiTitles = AppDatabase.instance.historyEntryDao().getDisplayTitles() .map { StringUtil.fromHtml(it).toString() } diff --git a/app/src/main/res/drawable/baseline_extension_24.xml b/app/src/main/res/drawable/baseline_extension_24.xml new file mode 100644 index 00000000000..8887b84ee34 --- /dev/null +++ b/app/src/main/res/drawable/baseline_extension_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_stars_24.xml b/app/src/main/res/drawable/baseline_stars_24.xml new file mode 100644 index 00000000000..03306ee68de --- /dev/null +++ b/app/src/main/res/drawable/baseline_stars_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/edit_history_ooui.xml b/app/src/main/res/drawable/edit_history_ooui.xml new file mode 100644 index 00000000000..4a4448436f1 --- /dev/null +++ b/app/src/main/res/drawable/edit_history_ooui.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/filled_family_star_24.xml b/app/src/main/res/drawable/filled_family_star_24.xml new file mode 100644 index 00000000000..3583cd7081c --- /dev/null +++ b/app/src/main/res/drawable/filled_family_star_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_newsstand_24.xml b/app/src/main/res/drawable/ic_newsstand_24.xml new file mode 100644 index 00000000000..fa10498e306 --- /dev/null +++ b/app/src/main/res/drawable/ic_newsstand_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_outline_lock_24.xml b/app/src/main/res/drawable/ic_outline_lock_24.xml new file mode 100644 index 00000000000..6c17e850744 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_lock_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_outline_stadia_controller_24.xml b/app/src/main/res/drawable/ic_outline_stadia_controller_24.xml new file mode 100644 index 00000000000..b1e43341396 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_stadia_controller_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/illustration_activity_tab_empty.xml b/app/src/main/res/drawable/illustration_activity_tab_empty.xml new file mode 100644 index 00000000000..a226e62baab --- /dev/null +++ b/app/src/main/res/drawable/illustration_activity_tab_empty.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/illustration_activity_tab_logged_out.xml b/app/src/main/res/drawable/illustration_activity_tab_logged_out.xml new file mode 100644 index 00000000000..80f1c5eb7ab --- /dev/null +++ b/app/src/main/res/drawable/illustration_activity_tab_logged_out.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/outline_activity_24.xml b/app/src/main/res/drawable/outline_activity_24.xml new file mode 100644 index 00000000000..9194f47b859 --- /dev/null +++ b/app/src/main/res/drawable/outline_activity_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_credit_card_heart_24.xml b/app/src/main/res/drawable/outline_credit_card_heart_24.xml new file mode 100644 index 00000000000..ce58e574488 --- /dev/null +++ b/app/src/main/res/drawable/outline_credit_card_heart_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_difference_24.xml b/app/src/main/res/drawable/outline_difference_24.xml new file mode 100644 index 00000000000..13241cadfae --- /dev/null +++ b/app/src/main/res/drawable/outline_difference_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_looks_one_24.xml b/app/src/main/res/drawable/outline_looks_one_24.xml new file mode 100644 index 00000000000..0f4c4e0ab54 --- /dev/null +++ b/app/src/main/res/drawable/outline_looks_one_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_looks_three_24.xml b/app/src/main/res/drawable/outline_looks_three_24.xml new file mode 100644 index 00000000000..bb32e9e91a8 --- /dev/null +++ b/app/src/main/res/drawable/outline_looks_three_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_looks_two_24.xml b/app/src/main/res/drawable/outline_looks_two_24.xml new file mode 100644 index 00000000000..d0433678b6f --- /dev/null +++ b/app/src/main/res/drawable/outline_looks_two_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_motion_blur_24.xml b/app/src/main/res/drawable/outline_motion_blur_24.xml new file mode 100644 index 00000000000..712476d2e04 --- /dev/null +++ b/app/src/main/res/drawable/outline_motion_blur_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_sports_score_24.xml b/app/src/main/res/drawable/outline_sports_score_24.xml new file mode 100644 index 00000000000..631864b5370 --- /dev/null +++ b/app/src/main/res/drawable/outline_sports_score_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_trending_up_24.xml b/app/src/main/res/drawable/outline_trending_up_24.xml new file mode 100644 index 00000000000..c2100b4f259 --- /dev/null +++ b/app/src/main/res/drawable/outline_trending_up_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/selector_nav_activity.xml b/app/src/main/res/drawable/selector_nav_activity.xml new file mode 100644 index 00000000000..4dd42599146 --- /dev/null +++ b/app/src/main/res/drawable/selector_nav_activity.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_suggested_edits_tasks.xml b/app/src/main/res/layout/fragment_suggested_edits_tasks.xml index 5f0f7819697..aa74c2bc4c6 100644 --- a/app/src/main/res/layout/fragment_suggested_edits_tasks.xml +++ b/app/src/main/res/layout/fragment_suggested_edits_tasks.xml @@ -42,9 +42,10 @@ android:id="@+id/messageCard" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginVertical="16dp" + android:layout_marginTop="16dp" android:layout_marginStart="@dimen/activity_horizontal_margin" android:layout_marginEnd="@dimen/activity_horizontal_margin" + android:layout_marginBottom="32dp" android:visibility="gone" /> + android:layout_marginTop="8dp" + android:layout_marginBottom="32dp"> + android:layout_height="wrap_content" + tools:ignore="UseCompoundDrawables,UselessParent"> + + + + + + + diff --git a/app/src/main/res/menu/menu_activity_tab_overflow.xml b/app/src/main/res/menu/menu_activity_tab_overflow.xml new file mode 100644 index 00000000000..3dd8f86eb2d --- /dev/null +++ b/app/src/main/res/menu/menu_activity_tab_overflow.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_suggested_edits_tasks.xml b/app/src/main/res/menu/menu_suggested_edits_tasks.xml new file mode 100644 index 00000000000..898cac1feb7 --- /dev/null +++ b/app/src/main/res/menu/menu_suggested_edits_tasks.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index c592073de6e..a3f167a8276 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -60,6 +60,7 @@ Navigation menu item label for accessing menu items.\n{{identical|More}} Navigation menu item label for search.\n\n{{Identical|Search}} Navigation menu item label for contribute.\n\n{{Identical|Contribute}} + Navigation menu item label for the user\'s activity. Error message shown when network cannot be reached when loading a page Text for button that retries page loading when tapped.\n{{Identical|Retry}} Text for button that retries loading the offline card in the event of an error while loading.\n{{Identical|Retry}} @@ -960,6 +961,7 @@ Button label for resetting the configuration of customize toolbar to default. Default tooltip text for customize toolbar drag handle Default tooltip text for customize toolbar item + Title of the suggested edits screen. Title of screen where the user chooses articles that are missing article descriptions to be described. Title of screen where the user chooses images that are missing captions to be captioned. Title of screen where the user chooses tags that represent an image. @@ -1167,6 +1169,89 @@ Edit summary when an image caption was added. Edit summary when an image and its caption were added. Button label for viewing an edit after it is published. + Title text shown in the Activity tab when the user is not logged in. + Title of the current user\'s reading activity. The %s is replaced with the user name. + Subtitle of the reading activity statistics, specific to the Wikipedia Android app. + Time spent reading this week, expressed in hours and minutes. The %1$d symbol is replaced with hours, and %2$d is replaced with minutes. + Label underneath the weekly time spent in the app. + Label on a card that lists the number of articles read this month. + Label on a card that lists the number of articles saved this month to a reading list. + Label on a card that lists the top categories of articles that were read this month. + Button label to go to the Reading Lists screen, which will give the user further guidance for exploring Wikipedia. + Label encouraging the user to discover new articles. + Label on card that shows the last donation time. + Title of the game stats card. + Label of the game stats that indicates the best streak. + Button label to go to the WikiGames. + Subtitle of the donation card that indicates last donation. + Subject heading for sending a report about a problem with the Activity tab feature. + Body of email for sending a report about a problem with the Activity tab feature. + Menu label for providing feedback on the activity tab. + Menu label for visiting the information page of the activity tab. + Menu label for opening the activity tab customization screen. + Text shown in the Customize screen explaining how Activity tab works. + Label for the Highlights section of the Activity tab. + Title of the switch for the Reading history module in the customize screen. + Label for the Impact section of the activity tab + Title of the most viewed card of the activity tab. + Title of the contributions card of the activity tab. + + Label of the contributions card that indicates the edit this month. + Label of the contributions card that indicates the edits this month. + + + Label of the contributions card that indicates the edit last month. + Label of the contributions card that indicates the edits last month. + + Title of the all time impact card of the activity tab. + + Label of the suggested edits card that indicates the number of total edits. + Label of the suggested edits card that indicates the number of total edits. + + + Label of the suggested edits card that indicates the number of thanks. + Label of the suggested edits card that indicates the number of thanks. + + + Text that indicates the length of the best streak. %d will be replaced by the number of days. + Text that indicates the length of the best streak. %d will be replaced by the number of days. + + Label of the suggested edits card that indicates the best streak + Label of the suggested edits card that indicates the last edited + Title of the suggested edits card in the impact section. + Message of the suggested edits card in the impact section. + Button label of the suggested edits card in the impact section. + Title of the recent activity section in the suggested edits card in the impact section. + + Label of the recent activity section in the suggested edits card that indicates the number of total edits. + Label of the recent activity section in the suggested edits card that indicates the number of total edits. + + + Label of the recent activity section in the suggested edits card that indicates the total views. + Label of the recent activity section in the suggested edits card that indicates the total views. + + Title of the switch to enable total time spent insights in the Activity Tab customization screen. + Title of the switch to enable reading insights in the Activity Tab customization screen. + Title of the switch to enable editing insights in the Activity Tab customization screen. + Title of the switch for the Impact module in the Activity Tab customization screen. + Title of the switch for the Games module in the Activity Tab customization screen. + Title of the switch for the Donations module in the Activity Tab customization screen. + Title of the switch for the Timeline module in the Activity Tab customization screen. + Message of the empty state when all modules are turned off. (Please preserve the anchor tag in its exact form) + Date separator label for today\'s entries in activity timeline. + Date separator label for yesterday\'s entries in activity timeline. + Title displayed when the timeline is empty. + Message shown when there are no items in the timeline. + Button label to view edit changes in the timeline. + Title for the activity tab onboarding screen. + Title for the section about user\'s reading habits. + Short explanation about user\'s reading habits. + Title for the section about user\'s impact. + Short explanation about the user\'s impact. + Title for the section about about ways to engage with Wikipedia.. + Short explanation about the stats, saved articles, and other activities that connect the user more deeply with Wikipedia. + Title for the section about privacy and control. + Short explanation about privacy and control. Title shown at the top of the activity for the file page. Button label to add image caption for the file. Button label to add image tags for the file. diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 27efcd48697..eaa2a56cd64 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -175,6 +175,9 @@ otdEntryDialogShown otdNotificationState otdGameFirstPlayedShown + activityTabRedDotShown + impactLastQueryTime + impactLastResponseBody placesDefaultLocationLatLng deleteLocalDonationHistory categoryPlayground @@ -199,4 +202,7 @@ donationReminderDevReset donationReminderDevResetSeenDate donations + activityTabModules + activityTabOnboardingShown + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c530c2f8b28..e7ad19938f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Search Filter my lists Edits + Activity More Search Contribute @@ -29,7 +30,7 @@ Go back Go back Next - Clear history + Clear reading history %s removed from history %d articles removed from history Undo @@ -44,7 +45,7 @@ Clear browsing history - This will delete all of your browsing history, and close any currently open tabs. Are you sure? + This will delete all of your browsing history. Are you sure? Yes No Share via @@ -976,6 +977,7 @@ + Edit Describe articles Caption images Tag images @@ -1189,6 +1191,90 @@ Added caption Added image and caption View + Log in or create an account to view your activity on the Wikipedia app + %s\'s reading + On Wikipedia Android + %1$dh %2$dm + Time spent reading this week + Articles read this month + Articles saved this month + Top categories read this month + Discover through Wikipedia + Looking for something new to read? + Unknown + Last donation in app]]> + Game stats + Best streak + Play WikiGames + Issue report - Activity Tab + I have encountered a problem with Activity Tab feature:\n- [Describe specific problem]\n\nThe behavior I would like to see is:\n- [Describe proposed solution] + Problem with feature + Learn more + Customize + Activity tab insights are based on the primary language set in settings and is leveraging local data with the exception of edits which are public. + Highlights + Reading history + Your impact + Most viewed since your edit + Contributions this month + + edit this month + edits this month + + + edit last month + edits last month + + All time impact + + total edit + total edits + + + thank + thanks + + + %d day + %d days + + Best streak + Last edited + 0 edits to articles recently + Looks like you haven\'t made an edit this month. Extend free knowledge by editing topics that matter most to you. + Make an edit + Your recent activity (last 30 days) + + Edit + Edits + + + View on articles you\'ve edited + Views on articles you\'ve edited + + Time spent reading + Reading insights + Editing insights + All time impact + Game stats + Last in app donation + Timeline of behavior + Switch them on to see updates in this tab.]]> + Today + Yesterday + Nothing to show + Start reading and editing to build your history + View changes + Reading patterns + See how much time you\'ve spent reading and which articles or topics you\'ve explored over time. + Introducing Activity + Impact highlights + Discover insights about your contributions and the reach of the knowledge you\'ve shared. + More ways to engage + Explore stats for games, saved articles, and other activities that connect you more deeply with Wikipedia. + Stay in control + Choose which modules to display. All personal data stays private on your device and browsing history can be cleared at anytime. + diff --git a/app/src/main/res/values/strings_no_translate.xml b/app/src/main/res/values/strings_no_translate.xml index 12701e7bd5c..12621b8955e 100644 --- a/app/src/main/res/values/strings_no_translate.xml +++ b/app/src/main/res/values/strings_no_translate.xml @@ -28,6 +28,7 @@ https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/iOS/Personalized_Wikipedia_Year_in_Review/How_your_data_is_used https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/Android/Rabbit_Holes https://www.mediawiki.org/wiki/Wikimedia_Apps/Team/Android/Customizable_Donation_Reminder_Experiment + TODO @string/wikimedia diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml index 7984a1769fa..de5d1e458b2 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -547,4 +547,21 @@ android:title="@string/preference_key_donation_reminder_config"/> + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e223c8f6d80..f61dae68df2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ mockitoVersion = "5.2.0" navigationCompose = "2.9.3" okHttpVersion = "5.1.0" orchestrator = "1.6.1" +pagingCompose = "3.3.6" pagingRuntimeKtx = "3.3.6" paletteKtx = "1.0.0" photoview = "1.0.3" @@ -102,6 +103,7 @@ mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = " okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okHttpVersion" } okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okHttpVersion" } okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttpVersion" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "pagingRuntimeKtx" } photoview = { module = "io.getstream:photoview", version.ref = "photoview" }