diff --git a/app/build.gradle b/app/build.gradle index 2fbbb209f85..87198f3048a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -213,6 +213,12 @@ dependencies { ksp libs.androidx.room.compiler implementation libs.androidx.room.ktx + implementation libs.androidx.ui.graphics + implementation libs.androidx.glance.appwidget + implementation libs.androidx.glance.material3 + implementation libs.androidx.glance.preview + implementation libs.androidx.glance.appwidget.preview + // For language detection during editing prodImplementation libs.com.google.mlkit.language.id betaImplementation libs.com.google.mlkit.language.id @@ -260,7 +266,6 @@ dependencies { androidTestImplementation libs.room.testing androidTestUtil libs.androidx.orchestrator - // Coil implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.coil.gif) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3ac9a3a7a91..ba4f280a85a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,3 +2,7 @@ -dontwarn com.google.re2j.Matcher -dontwarn com.google.re2j.Pattern + +# TODO: remove after reading challenge widget is removed +-keep class * implements androidx.glance.appwidget.action.ActionCallback { public (); } + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b01011494a7..c9311d7e136 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -418,6 +418,9 @@ android:name=".donate.donationreminder.DonationReminderActivity" android:windowSoftInputMode="adjustResize"/> + + + + + + + + + diff --git a/app/src/main/java/org/wikipedia/activity/BaseActivity.kt b/app/src/main/java/org/wikipedia/activity/BaseActivity.kt index 7c21878e1aa..7a22cb6f879 100644 --- a/app/src/main/java/org/wikipedia/activity/BaseActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/BaseActivity.kt @@ -1,7 +1,6 @@ package org.wikipedia.activity import android.content.Intent -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.MenuItem import android.view.MotionEvent @@ -10,6 +9,7 @@ import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -40,6 +40,7 @@ import org.wikipedia.games.onthisday.OnThisDayGameResultFragment import org.wikipedia.login.LoginActivity import org.wikipedia.main.MainActivity import org.wikipedia.notifications.NotificationPresenter +import org.wikipedia.onboarding.InitialOnboardingActivity import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.readinglist.ReadingListSyncBehaviorDialogs import org.wikipedia.readinglist.sync.ReadingListSyncAdapter @@ -52,6 +53,9 @@ import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.views.ImageZoomHelper +import org.wikipedia.widgets.readingchallenge.ReadingChallengeInstallWidgetDialog +import org.wikipedia.widgets.readingchallenge.ReadingChallengeOnboardingActivity +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository import org.wikipedia.yearinreview.YearInReviewActivity import org.wikipedia.yearinreview.YearInReviewOnboardingActivity import org.wikipedia.yearinreview.YearInReviewViewModel @@ -78,6 +82,15 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba } } + private val requestReadingChallengeActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (ReadingChallengeWidgetRepository.shouldShowWidgetInstallDialog()) { + ExclusiveBottomSheetPresenter.dismiss(supportFragmentManager) + ExclusiveBottomSheetPresenter.show(supportFragmentManager, + ReadingChallengeInstallWidgetDialog() + ) + } + } + private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> // TODO: Show message(s) to the user if they deny the permission } @@ -123,6 +136,7 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.paper_color)) maybeShowLoggedOutInBackgroundDialog() maybeShowYearInReview() + maybeShowReadingChallengePrompt() Prefs.localClassName = localClassName @@ -267,8 +281,15 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba } } + protected fun maybeShowReadingChallengePrompt() { + if (ReadingChallengeWidgetRepository.shouldShowOnboardingDialog() && + this !is ReadingChallengeOnboardingActivity && this !is InitialOnboardingActivity) { + requestReadingChallengeActivity.launch(ReadingChallengeOnboardingActivity.newIntent(this)) + } + } + private fun removeSplashBackground() { - window.setBackgroundDrawable(ColorDrawable(ResourceUtil.getThemedColor(this, R.attr.paper_color))) + window.setBackgroundDrawable(ResourceUtil.getThemedColor(this, R.attr.paper_color).toDrawable()) } private fun maybeShowLoggedOutInBackgroundDialog() { diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt index faae05b6d4e..7bcf5d4544b 100644 --- a/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt +++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabFragment.kt @@ -114,6 +114,7 @@ import org.wikipedia.history.HistoryEntry import org.wikipedia.history.HistoryFragment import org.wikipedia.login.LoginActivity import org.wikipedia.navtab.NavTab +import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs @@ -123,6 +124,8 @@ import org.wikipedia.usercontrib.UserContribListActivity import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.UiState import org.wikipedia.util.UriUtil +import org.wikipedia.widgets.readingchallenge.ReadingChallengeRewardDialog +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository import java.time.LocalDateTime class ActivityTabFragment : Fragment() { @@ -211,10 +214,19 @@ class ActivityTabFragment : Fragment() { Prefs.isGameStatsUnavailableSnackbarShown = true } } + maybeShowReadingChallengeRewardDialog() viewModel.loadAll() requireActivity().invalidateOptionsMenu() } + private fun maybeShowReadingChallengeRewardDialog() { + val intent = requireActivity().intent + if (ReadingChallengeWidgetRepository.shouldShowReward(intent)) { + intent.removeExtra(ReadingChallengeWidgetRepository.INTENT_EXTRA_READING_CHALLENGE_REWARD) + ExclusiveBottomSheetPresenter.show(childFragmentManager, ReadingChallengeRewardDialog()) + } + } + override fun onPause() { super.onPause() requireActivity().removeMenuProvider(menuProvider) diff --git a/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt b/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt index 151c7c19d67..1033ae7e50a 100644 --- a/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/activitytab/ActivityTabOnboardingActivity.kt @@ -103,6 +103,8 @@ fun OnboardingScreen( containerColor = WikipediaTheme.colors.paperColor, bottomBar = { TwoButtonBottomBar( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), primaryButtonText = stringResource(R.string.onboarding_continue), secondaryButtonText = stringResource(R.string.activity_tab_menu_info), onPrimaryOnClick = onContinueClick, diff --git a/app/src/main/java/org/wikipedia/compose/components/Onboarding.kt b/app/src/main/java/org/wikipedia/compose/components/Onboarding.kt index e806c87fffa..5af1984c4c4 100644 --- a/app/src/main/java/org/wikipedia/compose/components/Onboarding.kt +++ b/app/src/main/java/org/wikipedia/compose/components/Onboarding.kt @@ -20,6 +20,7 @@ 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 org.wikipedia.R @@ -56,7 +57,7 @@ fun OnboardingListItem( color = WikipediaTheme.colors.primaryColor ) Text( - text = stringResource(item.subTitle), + text = item.subtitleString ?: stringResource(item.subTitle), style = MaterialTheme.typography.bodyMedium, color = WikipediaTheme.colors.secondaryColor ) @@ -66,15 +67,14 @@ fun OnboardingListItem( @Composable fun TwoButtonBottomBar( + modifier: Modifier = Modifier, primaryButtonText: String, secondaryButtonText: String, onPrimaryOnClick: () -> Unit, onSecondaryOnClick: () -> Unit ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), + modifier = modifier, horizontalArrangement = Arrangement.spacedBy( space = 24.dp, alignment = Alignment.CenterHorizontally @@ -96,7 +96,8 @@ fun TwoButtonBottomBar( Text( text = secondaryButtonText, style = MaterialTheme.typography.labelLarge, - color = WikipediaTheme.colors.progressiveColor + color = WikipediaTheme.colors.progressiveColor, + textAlign = TextAlign.Center ) } @@ -114,7 +115,8 @@ fun TwoButtonBottomBar( Text( text = targetText, style = MaterialTheme.typography.labelLarge, - color = WikipediaTheme.colors.paperColor + color = WikipediaTheme.colors.paperColor, + textAlign = TextAlign.Center ) } } @@ -124,7 +126,8 @@ fun TwoButtonBottomBar( data class OnboardingItem( val icon: Int, val title: Int, - val subTitle: Int + val subTitle: Int = 0, + val subtitleString: String? = null ) @Preview @@ -142,3 +145,20 @@ private fun OnboardingListItemPreview() { ) } } + +@Preview +@Composable +private fun TwoButtonBottomBarPreview() { + BaseTheme( + currentTheme = Theme.DARK + ) { + TwoButtonBottomBar( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + "Foo", + "A rather long button label", + onPrimaryOnClick = { }, + onSecondaryOnClick = { } + ) + } +} diff --git a/app/src/main/java/org/wikipedia/compose/components/WikipediaAlertDialog.kt b/app/src/main/java/org/wikipedia/compose/components/WikipediaAlertDialog.kt index 73a9bb9c868..a70d9f42d8d 100644 --- a/app/src/main/java/org/wikipedia/compose/components/WikipediaAlertDialog.kt +++ b/app/src/main/java/org/wikipedia/compose/components/WikipediaAlertDialog.kt @@ -6,18 +6,21 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import org.wikipedia.compose.theme.WikipediaTheme @Composable fun WikipediaAlertDialog( + modifier: Modifier = Modifier, title: String, message: String, confirmButtonText: String, + confirmButtonColor: Color = WikipediaTheme.colors.progressiveColor, dismissButtonText: String, + dismissButtonColor: Color = WikipediaTheme.colors.progressiveColor, onDismissRequest: () -> Unit, onConfirmButtonClick: () -> Unit, - onDismissButtonClick: () -> Unit, - modifier: Modifier = Modifier + onDismissButtonClick: () -> Unit ) { AlertDialog( modifier = modifier, @@ -37,9 +40,7 @@ fun WikipediaAlertDialog( onDismissRequest = onDismissRequest, confirmButton = { TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = WikipediaTheme.colors.progressiveColor - ), + colors = ButtonDefaults.textButtonColors(contentColor = confirmButtonColor), onClick = onConfirmButtonClick ) { Text(confirmButtonText) @@ -47,9 +48,7 @@ fun WikipediaAlertDialog( }, dismissButton = { TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = WikipediaTheme.colors.progressiveColor - ), + colors = ButtonDefaults.textButtonColors(contentColor = dismissButtonColor), onClick = onDismissButtonClick ) { Text(dismissButtonText) diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt index 5ffb5536fa2..d37ebacdb9b 100644 --- a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt @@ -97,7 +97,7 @@ class CreateAccountActivity : BaseActivity() { // Only send event if the activity is created for the first time if (savedInstanceState == null) { - instrument?.submitInteraction("impression", actionContext = mapOf("create_source" to requestSource)) + instrument?.submitInteraction("impression", actionContext = mapOf("invoke_source" to requestSource)) } addFirstKeystrokeInstrumentation(binding.createAccountUsername.editText, "username") @@ -396,7 +396,7 @@ class CreateAccountActivity : BaseActivity() { setResult(RESULT_ACCOUNT_CREATED, resultIntent) showProgressBar(false) captchaHandler.cancelCaptcha() - instrument?.submitInteraction("success", actionContext = mapOf("create_source" to requestSource)) + instrument?.submitInteraction("success", actionContext = mapOf("invoke_source" to requestSource)) DeviceUtil.hideSoftKeyboard(this@CreateAccountActivity) finish() } diff --git a/app/src/main/java/org/wikipedia/login/LoginActivity.kt b/app/src/main/java/org/wikipedia/login/LoginActivity.kt index 9df5a766bc4..dfad6e85720 100644 --- a/app/src/main/java/org/wikipedia/login/LoginActivity.kt +++ b/app/src/main/java/org/wikipedia/login/LoginActivity.kt @@ -15,6 +15,7 @@ import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity @@ -40,6 +41,8 @@ import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil.visitInExternalBrowser import org.wikipedia.util.log.L import org.wikipedia.views.NonEmptyValidator +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository +import java.time.LocalDate class LoginActivity : BaseActivity() { private lateinit var binding: ActivityLoginBinding @@ -223,8 +226,18 @@ class LoginActivity : BaseActivity() { } private fun onLoginSuccess() { - instrument?.submitInteraction("success") - + val isReadingChallenge = loginSource == SOURCE_READING_CHALLENGE + instrument?.submitInteraction(action = "success", actionContext = if (isReadingChallenge) mapOf("invoke_source" to loginSource) else null) + if (isReadingChallenge) { + Prefs.readingChallengeEnrolled = true + Prefs.readingChallengeEnrollmentDate = LocalDate.now().toString() + lifecycleScope.launch { + if (ReadingChallengeWidgetRepository.isWidgetInstalled()) { + ReadingChallengeWidgetRepository(this@LoginActivity).updateWidgetsAndSendAnalytics() + } + } + intent.removeExtra(LOGIN_REQUEST_SOURCE) + } DeviceUtil.hideSoftKeyboard(this@LoginActivity) setResult(RESULT_LOGIN_SUCCESS) @@ -347,6 +360,7 @@ class LoginActivity : BaseActivity() { const val SOURCE_ACTIVITY_TAB = "activity_tab" const val SOURCE_YEAR_IN_REVIEW = "yir" const val SOURCE_ON_THIS_DAY_GAME_RESULT = "on_this_day_game_result" + const val SOURCE_READING_CHALLENGE = "widget_challenge" 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/MainActivity.kt b/app/src/main/java/org/wikipedia/main/MainActivity.kt index d1256ad159e..98f77b37f46 100644 --- a/app/src/main/java/org/wikipedia/main/MainActivity.kt +++ b/app/src/main/java/org/wikipedia/main/MainActivity.kt @@ -24,12 +24,15 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.FeedFragment import org.wikipedia.navtab.NavTab import org.wikipedia.onboarding.InitialOnboardingActivity +import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.settings.Prefs import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.ResourceUtil +import org.wikipedia.widgets.readingchallenge.ReadingChallengeInstallWidgetDialog +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository class MainActivity : SingleFragmentActivity(), MainFragment.Callback { @@ -114,6 +117,12 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba if (tab == NavTab.EDITS) { ImageRecommendationsEvent.logImpression("suggested_edit_dialog") PatrollerExperienceEvent.logImpression("suggested_edits_dialog") + + if (ReadingChallengeWidgetRepository.shouldShowWidgetInstallDialog()) { + ExclusiveBottomSheetPresenter.show(supportFragmentManager, + ReadingChallengeInstallWidgetDialog() + ) + } } binding.mainToolbarWordmark.visibility = View.GONE binding.mainToolbar.setTitle(tab.text) diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index 0ae6bb08d29..73d1e468dce 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -89,8 +89,10 @@ import org.wikipedia.views.FrameLayoutNavMenuTriggerer import org.wikipedia.views.ObservableWebView import org.wikipedia.views.ViewUtil import org.wikipedia.watchlist.WatchlistExpiry +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository import org.wikipedia.yearinreview.YearInReviewDialog import org.wikipedia.yearinreview.YearInReviewViewModel +import java.time.LocalDate import java.util.Locale class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.LoadPageCallback, FrameLayoutNavMenuTriggerer.Callback { @@ -420,6 +422,9 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo removeTransitionAnimState() maybeShowThemeTooltip() updateSearchHint() + lifecycleScope.launch { + ReadingChallengeWidgetRepository(this@PageActivity).updateOnArticleRead(LocalDate.now()) + } } override fun onPageDismissBottomSheet() { diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index df7393742ca..d2b39424789 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -29,6 +29,7 @@ import org.wikipedia.util.DateUtil.dbDateParse import org.wikipedia.util.ReleaseUtil.isDevRelease import org.wikipedia.util.StringUtil import org.wikipedia.watchlist.WatchlistFilterTypes +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository import org.wikipedia.yearinreview.YearInReviewModel import org.wikipedia.yearinreview.YearInReviewSurveyState import java.util.Date @@ -880,4 +881,42 @@ object Prefs { var isGameStatsUnavailableSnackbarShown get() = PrefsIoUtil.getBoolean(R.string.preference_key_game_stats_snackbar_shown, false) set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_game_stats_snackbar_shown, value) + + var readingChallengeStreak + get() = PrefsIoUtil.getInt(R.string.preference_key_reading_challenge_streak, 0) + set(value) = PrefsIoUtil.setInt(R.string.preference_key_reading_challenge_streak, value) + + var readingChallengeEnrolled + get() = PrefsIoUtil.getBoolean(R.string.preference_key_reading_challenge_enrolled, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_reading_challenge_enrolled, value) + + var readingChallengeLastReadDate + get() = PrefsIoUtil.getString(R.string.preference_key_reading_challenge_last_read_date, "").orEmpty() + set(value) = PrefsIoUtil.setString(R.string.preference_key_reading_challenge_last_read_date, value) + + var readingChallengeOnboardingShown + get() = PrefsIoUtil.getBoolean(R.string.preference_key_reading_challenge_onboarding_shown, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_reading_challenge_onboarding_shown, value) + + var readingChallengeInstallPromptShown + get() = PrefsIoUtil.getBoolean(R.string.preference_key_reading_challenge_install_prompt_shown, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_reading_challenge_install_prompt_shown, value) + + var readingChallengeEnrollmentDate + get() = PrefsIoUtil.getString(R.string.preference_key_reading_challenge_enrollment_date, "").orEmpty() + set(value) = PrefsIoUtil.setString(R.string.preference_key_reading_challenge_enrollment_date, value) + + var readingChallengeEndDate + get() = PrefsIoUtil.getString(R.string.preference_key_reading_challenge_end_date, + ReadingChallengeWidgetRepository.READING_CHALLENGE_END_DATE).orEmpty() + set(value) = PrefsIoUtil.setString(R.string.preference_key_reading_challenge_end_date, value) + + var readingChallengeStartDate + get() = PrefsIoUtil.getString(R.string.preference_key_reading_challenge_start_date, + ReadingChallengeWidgetRepository.READING_CHALLENGE_START_DATE).orEmpty() + set(value) = PrefsIoUtil.setString(R.string.preference_key_reading_challenge_start_date, value) + + var readingChallengeWidgetFastCycle + get() = PrefsIoUtil.getBoolean(R.string.preference_key_reading_challenge_widget_fast_cycle, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_reading_challenge_widget_fast_cycle, value) } diff --git a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt index 32c7e0421a7..1561791f16f 100644 --- a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt @@ -3,6 +3,7 @@ package org.wikipedia.settings.dev import android.content.DialogInterface import android.content.Intent import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.Preference @@ -22,6 +23,7 @@ import org.wikipedia.games.onthisday.OnThisDayGameNotificationManager import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.history.HistoryEntry import org.wikipedia.notifications.NotificationPollBroadcastReceiver +import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.database.ReadingListPage @@ -30,9 +32,11 @@ import org.wikipedia.readinglist.recommended.RecommendedReadingListUpdateFrequen import org.wikipedia.settings.BasePreferenceLoader import org.wikipedia.settings.Prefs import org.wikipedia.settings.dev.playground.CategoryDeveloperPlayGround +import org.wikipedia.settings.dev.playground.ReadingChallengePlayGroundDialog import org.wikipedia.setupLeakCanary import org.wikipedia.suggestededits.provider.EditingSuggestionsProvider import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.StringUtil.fromHtml import org.wikipedia.yearinreview.YearInReviewSurveyState @@ -268,6 +272,13 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom findPreference(R.string.preference_key_event_platform_intake_base_uri).summary = selectedState true } + findPreference(R.string.preference_key_reading_challenge_widgets).apply { + isVisible = ReleaseUtil.isPreProdRelease + onPreferenceClickListener = Preference.OnPreferenceClickListener { + ExclusiveBottomSheetPresenter.show((activity as AppCompatActivity).supportFragmentManager, ReadingChallengePlayGroundDialog()) + true + } + } } private fun setUpMediaWikiSettings() { diff --git a/app/src/main/java/org/wikipedia/settings/dev/playground/ReadingChallengePlaygroundDialog.kt b/app/src/main/java/org/wikipedia/settings/dev/playground/ReadingChallengePlaygroundDialog.kt new file mode 100644 index 00000000000..89aaf6d4054 --- /dev/null +++ b/app/src/main/java/org/wikipedia/settings/dev/playground/ReadingChallengePlaygroundDialog.kt @@ -0,0 +1,536 @@ +package org.wikipedia.settings.dev.playground + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.widgets.readingchallenge.ReadingChallengeState +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetWorker +import java.time.LocalDate + +class ReadingChallengePlayGroundDialog : ExtendedBottomSheetDialogFragment(startExpanded = true) { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View { + val repository = ReadingChallengeWidgetRepository(requireContext()) + + return ComposeView(requireContext()).apply { + setContent { + val stateFlow = remember { repository.observeState() } + val coroutineScope = rememberCoroutineScope() + + BaseTheme { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { dismiss() }, + content = { + Icon( + painter = painterResource(R.drawable.ic_arrow_back_black_24dp), + contentDescription = stringResource(R.string.nav_item_back), + tint = WikipediaTheme.colors.primaryColor + ) + } + ) + Text( + text = "Reading Challenge Playground", + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ), + color = WikipediaTheme.colors.primaryColor, + modifier = Modifier.padding(start = 8.dp) + ) + } + ReadingChallengePlayground( + modifier = Modifier.padding(16.dp), + state = stateFlow.collectAsState(initial = ReadingChallengeState.NotLiveYet).value, + updateWidgetsExplicitly = { + coroutineScope.launch { + ReadingChallengeWidgetRepository(requireContext()).updateWidgetsAndSendAnalytics() + } + }, + updateWidgetsUpdateFrequency = { + ReadingChallengeWidgetWorker.scheduleNextWidgetUpdate(requireContext()) + } + ) + } + } + } + } + } + + override fun onStart() { + super.onStart() + dialog?.let { + it.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.let { view -> + BottomSheetBehavior.from(view).isDraggable = false + } + } + } +} + +@Composable +fun ReadingChallengePlayground( + modifier: Modifier = Modifier, + state: ReadingChallengeState, + updateWidgetsExplicitly: () -> Unit, + updateWidgetsUpdateFrequency: () -> Unit +) { + var streak by remember { mutableIntStateOf(Prefs.readingChallengeStreak) } + var enrolled by remember { mutableStateOf(Prefs.readingChallengeEnrolled) } + var lastReadDate by remember { mutableStateOf(Prefs.readingChallengeLastReadDate) } + var startDate by remember { mutableStateOf(Prefs.readingChallengeStartDate) } + var endDate by remember { mutableStateOf(Prefs.readingChallengeEndDate) } + var onboardingShown by remember { mutableStateOf(Prefs.readingChallengeOnboardingShown) } + var widgetPromptShown by remember { mutableStateOf(Prefs.readingChallengeInstallPromptShown) } + var fastCycle by remember { mutableStateOf(Prefs.readingChallengeWidgetFastCycle) } + + fun syncFromPrefs() { + streak = Prefs.readingChallengeStreak + enrolled = Prefs.readingChallengeEnrolled + lastReadDate = Prefs.readingChallengeLastReadDate + startDate = Prefs.readingChallengeStartDate + endDate = Prefs.readingChallengeEndDate + onboardingShown = Prefs.readingChallengeOnboardingShown + widgetPromptShown = Prefs.readingChallengeInstallPromptShown + } + + val today = LocalDate.now().toString() + val yesterday = LocalDate.now().minusDays(1).toString() + val threeDaysAgo = LocalDate.now().minusDays(3).toString() + + val switchColor = SwitchDefaults.colors( + uncheckedTrackColor = WikipediaTheme.colors.paperColor, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedBorderColor = MaterialTheme.colorScheme.outline, + checkedTrackColor = WikipediaTheme.colors.progressiveColor, + checkedThumbColor = WikipediaTheme.colors.paperColor + ) + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "Current State: ${state::class.simpleName}", + style = MaterialTheme.typography.titleLarge, + color = WikipediaTheme.colors.primaryColor + ) + + // --- Streak --- + Card { + Column( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "readingChallengeStreak", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton(onClick = { + if (streak > 0) { + Prefs.readingChallengeStreak = --streak + updateWidgetsExplicitly() + } + }) { Text("−") } + Text( + text = "$streak", + style = MaterialTheme.typography.headlineMedium, + color = WikipediaTheme.colors.primaryColor + ) + IconButton(onClick = { + Prefs.readingChallengeStreak = ++streak + updateWidgetsExplicitly() + }) { Text("+", color = WikipediaTheme.colors.primaryColor) } + } + } + } + + // --- Enrolled --- + Card { + Row( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = "readingChallengeEnrolled", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Switch( + checked = enrolled, + onCheckedChange = { + enrolled = it + Prefs.readingChallengeEnrolled = it + Prefs.readingChallengeEnrollmentDate = LocalDate.now().toString() + updateWidgetsExplicitly() + }, + colors = switchColor + ) + } + } + + if (state is ReadingChallengeState.EnrolledNotStarted || state is ReadingChallengeState.StreakOngoingNeedsReading || state is ReadingChallengeState.StreakOngoingReadToday) { + // --- Fast Cycle --- + Card { + Row( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = "readingChallengeWidgetFastCycle", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Text( + text = "Updates every 1 min instead of midnight", + style = MaterialTheme.typography.bodySmall, + color = WikipediaTheme.colors.secondaryColor + ) + } + Switch( + checked = fastCycle, + onCheckedChange = { + fastCycle = it + Prefs.readingChallengeWidgetFastCycle = it + updateWidgetsUpdateFrequency() + }, + colors = switchColor + ) + } + } + } + + // --- OnBoarding --- + Card { + Row( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = "readingChallengeOnboardingShown", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Switch( + checked = onboardingShown, + onCheckedChange = { + onboardingShown = it + Prefs.readingChallengeOnboardingShown = it + updateWidgetsExplicitly() + }, + colors = switchColor + ) + } + } + + // --- Widget prompt --- + Card { + Row( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = "readingChallengeInstallPromptShown", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Switch( + checked = widgetPromptShown, + onCheckedChange = { + widgetPromptShown = it + Prefs.readingChallengeInstallPromptShown = it + updateWidgetsExplicitly() + }, + colors = switchColor + ) + } + } + + // --- Last Read Date --- + Card { + Column( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "readingChallengeLastReadDate", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = lastReadDate, + onValueChange = { + lastReadDate = it + }, + placeholder = { Text("YYYY-MM-DD", color = WikipediaTheme.colors.primaryColor) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = WikipediaTheme.colors.primaryColor, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedTextColor = WikipediaTheme.colors.primaryColor, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = WikipediaTheme.colors.primaryColor, + errorTextColor = WikipediaTheme.colors.primaryColor, + ), + modifier = Modifier.weight(1f) + ) + Button( + onClick = { + Prefs.readingChallengeLastReadDate = lastReadDate + updateWidgetsExplicitly() + }, + enabled = lastReadDate.isEmpty() || runCatching { LocalDate.parse(lastReadDate) }.isSuccess, + colors = ButtonDefaults.buttonColors( + containerColor = WikipediaTheme.colors.progressiveColor, + contentColor = WikipediaTheme.colors.paperColor + ) + ) { + Text("Save") + } + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { + Prefs.readingChallengeLastReadDate = today + lastReadDate = today + updateWidgetsExplicitly() + }, label = { Text("Today", color = WikipediaTheme.colors.primaryColor) }) + AssistChip(onClick = { + Prefs.readingChallengeLastReadDate = yesterday + lastReadDate = yesterday + updateWidgetsExplicitly() + }, label = { Text("Yesterday", color = WikipediaTheme.colors.primaryColor) }) + AssistChip(onClick = { + Prefs.readingChallengeLastReadDate = threeDaysAgo + lastReadDate = threeDaysAgo + updateWidgetsExplicitly() + }, label = { Text("3 Days Ago", color = WikipediaTheme.colors.primaryColor) }) + AssistChip(onClick = { + Prefs.readingChallengeLastReadDate = "" + lastReadDate = "" + updateWidgetsExplicitly() + }, label = { Text("Clear", color = WikipediaTheme.colors.primaryColor) }) + } + } + } + + // --- Challenge Start Date --- + Card { + Column( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "readingChallengeStartDate", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = startDate, + onValueChange = { + startDate = it + }, + placeholder = { + Text( + "YYYY-MM-DD", + color = WikipediaTheme.colors.primaryColor + ) + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = WikipediaTheme.colors.primaryColor, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedTextColor = WikipediaTheme.colors.primaryColor, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = WikipediaTheme.colors.primaryColor, + errorTextColor = WikipediaTheme.colors.primaryColor, + ), + modifier = Modifier.weight(1f) + ) + Button( + onClick = { + Prefs.readingChallengeStartDate = startDate + updateWidgetsExplicitly() + }, + enabled = startDate.isEmpty() || runCatching { LocalDate.parse(startDate) }.isSuccess, + colors = ButtonDefaults.buttonColors( + containerColor = WikipediaTheme.colors.progressiveColor, + contentColor = WikipediaTheme.colors.paperColor + ) + ) { + Text("Save") + } + } + } + } + + // --- Challenge End Date --- + Card { + Column( + Modifier + .background(WikipediaTheme.colors.backgroundColor) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "readingChallengeEndDate", + style = MaterialTheme.typography.labelMedium, + color = WikipediaTheme.colors.primaryColor + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = endDate, + onValueChange = { + endDate = it + }, + placeholder = { + Text( + "YYYY-MM-DD", + color = WikipediaTheme.colors.primaryColor + ) + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = WikipediaTheme.colors.primaryColor, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedTextColor = WikipediaTheme.colors.primaryColor, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = WikipediaTheme.colors.primaryColor, + errorTextColor = WikipediaTheme.colors.primaryColor, + ), + modifier = Modifier.weight(1f) + ) + Button( + onClick = { + Prefs.readingChallengeEndDate = endDate + updateWidgetsExplicitly() + }, + enabled = endDate.isEmpty() || runCatching { LocalDate.parse(endDate) }.isSuccess, + colors = ButtonDefaults.buttonColors( + containerColor = WikipediaTheme.colors.progressiveColor, + contentColor = WikipediaTheme.colors.paperColor + ) + ) { + Text("Save") + } + } + } + } + + // --- Reset --- + OutlinedButton( + onClick = { + Prefs.readingChallengeStreak = 0 + Prefs.readingChallengeEnrolled = false + Prefs.readingChallengeLastReadDate = "" + Prefs.readingChallengeEndDate = ReadingChallengeWidgetRepository.READING_CHALLENGE_END_DATE + Prefs.readingChallengeStartDate = ReadingChallengeWidgetRepository.READING_CHALLENGE_START_DATE + syncFromPrefs() + updateWidgetsExplicitly() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("Reset All", color = WikipediaTheme.colors.primaryColor) + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeActions.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeActions.kt new file mode 100644 index 00000000000..e704e4766b8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeActions.kt @@ -0,0 +1,90 @@ +package org.wikipedia.widgets.readingchallenge + +import android.content.Context +import android.content.Intent +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import org.wikipedia.Constants +import org.wikipedia.Constants.InvokeSource +import org.wikipedia.WikipediaApp +import org.wikipedia.main.MainActivity +import org.wikipedia.navtab.NavTab +import org.wikipedia.random.RandomActivity +import org.wikipedia.search.SearchActivity +import org.wikipedia.settings.Prefs + +class JoinChallengeAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + ReadingChallengeAnalyticsHelper.logAppOpenFromWidget() + Prefs.readingChallengeOnboardingShown = false + context.startActivity( + MainActivity.newIntent(context) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } +} + +class SearchAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + ReadingChallengeAnalyticsHelper.logAppOpenFromWidget() + context.startActivity( + SearchActivity.newIntent(context, InvokeSource.WIDGET, null).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } +} + +class RandomizerAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + ReadingChallengeAnalyticsHelper.logAppOpenFromWidget() + context.startActivity( + RandomActivity.newIntent(context, WikipediaApp.instance.wikiSite, InvokeSource.WIDGET).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } +} + +class ChallengeRewardAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + ReadingChallengeAnalyticsHelper.logAppOpenFromWidget() + context.startActivity( + MainActivity.newIntent(context) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(Constants.INTENT_EXTRA_GO_TO_SE_TAB, NavTab.EDITS.code()) + .putExtra(ReadingChallengeWidgetRepository.INTENT_EXTRA_READING_CHALLENGE_REWARD, true) + ) + } +} + +class HomeAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + ReadingChallengeAnalyticsHelper.logAppOpenFromWidget() + context.startActivity( + MainActivity.newIntent(context) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeAnalyticsHelper.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeAnalyticsHelper.kt new file mode 100644 index 00000000000..043860af846 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeAnalyticsHelper.kt @@ -0,0 +1,38 @@ +package org.wikipedia.widgets.readingchallenge + +import org.wikipedia.analytics.testkitchen.TestKitchenAdapter +import org.wikipedia.settings.Prefs + +object ReadingChallengeAnalyticsHelper { + fun sendHeartbeatEvent(state: ReadingChallengeState) { + when (state) { + ReadingChallengeState.ChallengeCompleted -> logHeartbeat(elementId = "challenge_completed", includeStreak = true) + is ReadingChallengeState.ChallengeConcludedIncomplete -> logHeartbeat(elementId = "challenge_incomplete", includeStreak = true) + ReadingChallengeState.ChallengeConcludedNoStreak -> logHeartbeat(elementId = "challenge_no_streak", includeStreak = true) + is ReadingChallengeState.StreakOngoingNeedsReading -> logHeartbeat(elementId = "streak_ongoing", includeStreak = true) + is ReadingChallengeState.StreakOngoingReadToday -> logHeartbeat(elementId = "streak_ongoing_read", includeStreak = true) + ReadingChallengeState.NotEnrolled -> logHeartbeat(elementId = "not_enrolled") + ReadingChallengeState.NotLiveYet -> logHeartbeat(elementId = "not_yet_live") + ReadingChallengeState.EnrolledNotStarted -> logHeartbeat(elementId = "enrolled_not_started") + else -> {} + } + } + + fun logAppOpenFromWidget() { + TestKitchenAdapter.client.getInstrument("apps-open") + .submitInteraction( + action = "app_open", + actionSource = "widget", + actionSubtype = "reading_challenge" + ) + } + + private fun logHeartbeat(elementId: String, includeStreak: Boolean = false) { + TestKitchenAdapter.client.getInstrument("apps-widgetchallenge").submitInteraction( + action = "heartbeat", + actionSource = "widget_challenge", + elementId = elementId, + actionContext = if (includeStreak) mapOf("streak_count" to Prefs.readingChallengeStreak) else null + ) + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeInstallWidgetDialog.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeInstallWidgetDialog.kt new file mode 100644 index 00000000000..119ce503e1c --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeInstallWidgetDialog.kt @@ -0,0 +1,236 @@ +package org.wikipedia.widgets.readingchallenge + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.Image +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.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.ContentScale +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.DpOffset +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.analytics.testkitchen.TestKitchenAdapter +import org.wikipedia.compose.components.TwoButtonBottomBar +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.theme.Theme + +class ReadingChallengeInstallWidgetDialog : ExtendedBottomSheetDialogFragment(startExpanded = true) { + + private val instrument = TestKitchenAdapter.client.getInstrument("apps-widgetchallenge") + .setDefaultActionSource("widget_challenge_install") + .startFunnel("widget_challenge") + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View { + instrument.submitInteraction(action = "impression") + Prefs.readingChallengeInstallPromptShown = true + + return ComposeView(requireContext()).apply { + setContent { + BaseTheme { + InstallWidgetScreen( + pinToWidgetSupported = pinWidgetSupported(), + onCloseClick = { + instrument.submitInteraction( + action = "click", + elementId = "install_close" + ) + dismiss() + }, + onGotItClick = { + instrument.submitInteraction( + action = "click", + elementId = "install_accept" + ) + dismiss() + }, + onAddClick = { + instrument.submitInteraction( + action = "click", + elementId = "install_add" + ) + requestToPinWidget(requireContext()) + dismiss() + } + ) + } + } + } + } + + private fun pinWidgetSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + AppWidgetManager.getInstance(requireContext()).isRequestPinAppWidgetSupported + } + + private fun requestToPinWidget(context: Context) { + if (pinWidgetSupported()) { + val successCallback = PendingIntent.getBroadcast( + context, 0, + Intent(context, ReadingChallengeWidgetReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + AppWidgetManager.getInstance(context).requestPinAppWidget(ComponentName(context, ReadingChallengeWidgetReceiver::class.java), null, successCallback) + } + } + + @Composable + fun InstallWidgetScreen( + pinToWidgetSupported: Boolean, + onCloseClick: () -> Unit, + onGotItClick: () -> Unit, + onAddClick: () -> Unit + ) { + Column( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Row { + Text( + modifier = Modifier + .weight(1f) + .padding(bottom = 16.dp), + text = stringResource(R.string.reading_challenge_install_prompt_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + + IconButton( + modifier = Modifier + .offset(x = 12.dp, y = (-6).dp), + onClick = { + onCloseClick() + } + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_close_black_24dp), + contentDescription = stringResource(R.string.dialog_close_description), + tint = WikipediaTheme.colors.primaryColor + ) + } + } + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.reading_challenge_install_prompt_message), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.secondaryColor + ) + + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .height(190.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + ) { + Image( + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.FillWidth, + painter = painterResource(id = R.drawable.reading_challenge_blur_background), + contentDescription = null + ) + Image( + modifier = Modifier + .align(Alignment.Center) + .padding(vertical = 24.dp) + .dropShadow( + shape = RoundedCornerShape(12.dp), + shadow = Shadow( + color = WikipediaTheme.colors.overlayColor, + radius = 12.dp, + offset = DpOffset(0.dp, 12.dp) + ) + ), + painter = painterResource(id = R.drawable.reading_challenge_widget_example), + contentDescription = null + ) + } + + if (pinToWidgetSupported) { + TwoButtonBottomBar( + modifier = Modifier.fillMaxWidth(), + primaryButtonText = stringResource(R.string.reading_challenge_install_prompt_add), + secondaryButtonText = stringResource(R.string.reading_challenge_install_prompt_got_it), + onPrimaryOnClick = onAddClick, + onSecondaryOnClick = onGotItClick + ) + } else { + Button( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = WikipediaTheme.colors.progressiveColor + ), + onClick = onGotItClick + ) { + Text( + text = stringResource(R.string.reading_challenge_install_prompt_got_it), + style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.paperColor, + textAlign = TextAlign.Center + ) + } + } + } + } + + @Preview(showBackground = true) + @Composable + private fun InstallWidgetScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InstallWidgetScreen( + pinToWidgetSupported = false, + onCloseClick = {}, + onAddClick = {}, + onGotItClick = {} + ) + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeLargeWidgetContent.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeLargeWidgetContent.kt new file mode 100644 index 00000000000..056e5b3aa1c --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeLargeWidgetContent.kt @@ -0,0 +1,637 @@ +package org.wikipedia.widgets.readingchallenge + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.wikipedia.R +import org.wikipedia.widgets.readingchallenge.WidgetCombinations.forToday +import java.time.LocalDate + +@Composable +fun ReadingChallengeLargeWidgetContent( + state: ReadingChallengeState, + enrollmentDate: LocalDate +) { + val context = LocalContext.current + when (state) { + ReadingChallengeState.Loading -> { + ReadingChallengeWidgetLoading( + modifier = GlanceModifier + .fillMaxSize() + ) + } + ReadingChallengeState.ChallengeCompleted -> { + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak_final_large, + ReadingChallengeWidgetRepository.READING_STREAK_GOAL, ReadingChallengeWidgetRepository.READING_STREAK_GOAL, ReadingChallengeWidgetRepository.READING_STREAK_GOAL) + GeneralLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.joinChallengeBackground, + textColor = WidgetColors.primary, + title = context.getString(R.string.reading_challenge_widget_concluded_complete), + subTitleContent = { + WidgetBadge( + text = streakText, + textSize = 16.sp, + iconResId = R.drawable.ic_flame_24dp, + iconSize = 24.dp, + iconTintColor = WidgetColors.primary, + textColor = WidgetColors.primary + ) + }, + expandMascot = true, + mainImageResId = R.drawable.wp25_babyglobe_celebration_neutral, + bottomContent = { + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_collect_your_prize_button), + action = actionRunCallback(), + modifier = GlanceModifier + ) + } + ) + } + is ReadingChallengeState.ChallengeConcludedIncomplete -> { + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak_final_large, + ReadingChallengeWidgetRepository.READING_STREAK_GOAL, state.streak, ReadingChallengeWidgetRepository.READING_STREAK_GOAL) + GeneralLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.joinChallengeBackground, + textColor = WidgetColors.primary, + title = context.getString(R.string.reading_challenge_widget_concluded_incomplete), + mainImageResId = R.drawable.wp25_babyglobe_reading, + expandMascot = true, + bottomContent = { + WidgetButton( + text = streakText, + action = actionRunCallback(), + backgroundColor = WidgetColors.challengeConcludedIncompleteButtonBackground, + contentColor = WidgetColors.primary, + icon = ImageProvider(R.drawable.ic_flame_24dp), + modifier = GlanceModifier + ) + } + ) + } + ReadingChallengeState.ChallengeConcludedNoStreak, ReadingChallengeState.ChallengeRemoved -> { + GeneralLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.joinChallengeBackground, + textColor = WidgetColors.primary, + title = context.getString(R.string.reading_challenge_widget_concluded_incomplete), + mainImageResId = R.drawable.wp25_babyglobe_reading, + expandMascot = true + ) + } + ReadingChallengeState.EnrolledNotStarted -> { + val combination = WidgetCombinations.enrolledNotStarted.forToday(enrollmentDate = enrollmentDate) + EnrolledNotStartedLargeWidget( + mainImageResId = combination.iconResId, + backgroundColor = combination.backgroundColor, + contentColor = combination.contentColor, + titleResId = combination.titleResId ?: R.string.reading_challenge_widget_enrolled_not_started_title, + subtitleReId = combination.subtitleResId ?: R.string.reading_challenge_widget_enrolled_not_started_subtitle + ) + } + ReadingChallengeState.NotEnrolled -> { + GeneralLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.joinChallengeBackground, + textColor = WidgetColors.primary, + title = context.getString(R.string.reading_challenge_widget_not_opted_in_title), + subTitle = context.getString(R.string.reading_challenge_widget_not_opted_in_description), + mainImageResId = R.drawable.wp25_babyglobe_reading, + bottomContent = { + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_join_the_challenge_button), + action = actionRunCallback() + ) + } + ) + } + ReadingChallengeState.NotLiveYet -> { + GeneralLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.challengeNotLiveBackground, + textColor = WidgetColors.primary, + title = context.getString(R.string.reading_challenge_widget_not_live_title), + subTitle = context.getString(R.string.reading_challenge_widget_not_live_description), + mainImageResId = R.drawable.wp25_babyglobe_reading, + bottomContent = { + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_explore_wikipedia_button), + action = actionRunCallback() + ) + } + ) + } + is ReadingChallengeState.StreakOngoingNeedsReading -> { + val combination = WidgetCombinations.streakNeedsReading.forToday(enrollmentDate = enrollmentDate) + StreakOngoingNeedsReadingLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clickable(onClick = actionRunCallback()), + reminderTextResId = combination.titleResId ?: R.string.reading_challenge_widget_reminder_dont_let_today_drift, + backgroundColor = combination.backgroundColor, + contentColor = combination.contentColor, + state = state, + mascotImageResId = combination.iconResId + ) + } + is ReadingChallengeState.StreakOngoingReadToday -> { + val combination = WidgetCombinations.streakOngoing.forToday(enrollmentDate = enrollmentDate) + StreakOngoingLargeWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clickable(onClick = actionRunCallback()), + backgroundColor = combination.backgroundColor, + contentColor = combination.contentColor, + progressColor = combination.progressColor ?: WidgetColors.phoneReadingProgressColor, + state = state, + mascotImageResId = combination.iconResId + ) + } + } +} + +@Composable +fun StreakOngoingLargeWidget( + state: ReadingChallengeState.StreakOngoingReadToday, + mascotImageResId: Int, + modifier: GlanceModifier = GlanceModifier, + backgroundColor: Color, + contentColor: Color, + progressColor: Color, + titleBarIcon: Int = R.drawable.ic_w_logo_shadow +) { + val context = LocalContext.current + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak, state.streak, state.streak) + val size = LargeWidgetSize.from(LocalSize.current) + + BaseWidgetContent( + color = backgroundColor + ) { + Box( + modifier = modifier + ) { + Column(modifier = GlanceModifier.fillMaxSize()) { + // Top Row: Trophy, Title, W logo + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(R.drawable.ic_trophy24dp), + contentDescription = null, + modifier = GlanceModifier.size(24.dp), + colorFilter = ColorFilter.tint(ColorProvider(day = contentColor, night = contentColor)) + ) + + Spacer(modifier = GlanceModifier.width(12.dp)) + + Text( + text = context.getString(R.string.reading_challenge_streak_ongoing_title), + style = TextStyle( + color = ColorProvider(day = contentColor, night = contentColor), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + ) + + Spacer(modifier = GlanceModifier.defaultWeight()) + + // W logo (Placeholder) + Image( + provider = ImageProvider(titleBarIcon), + contentDescription = null, + modifier = GlanceModifier.size(size.titleBarIconSize) + ) + } + + // Middle Row: Flame, Days. (With defaultWeight, it dynamically pushes the rows apart to match the design spacing perfectly) + WidgetBadge( + modifier = GlanceModifier + .defaultWeight(), + text = streakText, + iconResId = R.drawable.ic_flame_24dp, + textSize = size.streakBadgeTextSize, + iconSize = size.streakBadgeIconSize, + iconTintColor = contentColor, + textColor = contentColor + ) + + // Bottom Row: Progress bar + BoxedStreakProgressBar( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + currentStreak = state.streak, + totalDays = ReadingChallengeWidgetRepository.READING_STREAK_GOAL, + startIconResId = R.drawable.ic_calendar_day_1, + endIconResId = R.drawable.ic_calendar_day_25, + backgroundColor = contentColor, + progressColor = progressColor, + progressBarColor = WidgetColors.white + ) + } + + // Mascot overlay positioned absolutely inside the Box bounds + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + Image( + provider = ImageProvider(mascotImageResId), + contentDescription = null, + modifier = GlanceModifier + .size(size.overlayMascotSize) + .padding(end = 24.dp, bottom = 32.dp) + ) + } + } + } +} + +@Composable +fun StreakOngoingNeedsReadingLargeWidget( + state: ReadingChallengeState.StreakOngoingNeedsReading, + titleBarIcon: Int = R.drawable.ic_w_logo_shadow, + reminderTextResId: Int, + backgroundColor: Color, + mascotImageResId: Int, + modifier: GlanceModifier = GlanceModifier, + contentColor: Color +) { + val context = LocalContext.current + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak, state.streak, state.streak) + val reminderText = context.getString(reminderTextResId) + + val size = LargeWidgetSize.from(LocalSize.current) + + BaseWidgetContent( + color = backgroundColor + ) { + Column ( + modifier = modifier + ) { + Row ( + modifier = GlanceModifier + .defaultWeight() + .fillMaxWidth() + ) { + Column( + modifier = GlanceModifier + .defaultWeight() + ) { + WidgetBadge( + text = streakText, + textSize = size.streakBadgeTextSize, + iconResId = R.drawable.ic_flame_24dp, + iconSize = size.streakBadgeIconSize, + iconTintColor = contentColor, + textColor = contentColor + ) + Text( + text = reminderText, + style = TextStyle( + fontSize = size.subtitleTextSize, + color = ColorProvider(day = contentColor, night = contentColor), + fontWeight = FontWeight.Medium, + ) + ) + } + + Column( + modifier = GlanceModifier + .defaultWeight() + .fillMaxHeight(), + horizontalAlignment = Alignment.End + ) { + Image( + provider = ImageProvider(titleBarIcon), + contentDescription = null, + modifier = GlanceModifier.size(36.dp) + ) + Spacer(modifier = GlanceModifier.defaultWeight()) + Image( + provider = ImageProvider(mascotImageResId), + contentDescription = null, + modifier = GlanceModifier + .size(size.sideMascotSize) + ) + Spacer(modifier = GlanceModifier.size(24.dp)) + } + } + + Row( + modifier = GlanceModifier + .fillMaxWidth() + ) { + WidgetIconButton( + modifier = GlanceModifier + .defaultWeight(), + text = context.getString(R.string.reading_challenge_widget_search_button), + iconResId = R.drawable.outline_search_24, + action = actionRunCallback() + ) + Spacer(modifier = GlanceModifier.width(16.dp)) + WidgetIconButton( + modifier = GlanceModifier + .defaultWeight(), + text = context.getString(R.string.reading_challenge_widget_random_button), + iconResId = R.drawable.ic_dice_24, + action = actionRunCallback() + ) + } + } + } +} + +@Composable +fun EnrolledNotStartedLargeWidget( + titleBarIcon: Int = R.drawable.ic_w_logo_shadow, + mainImageResId: Int, + backgroundColor: Color, + contentColor: Color, + titleResId: Int, + subtitleReId: Int +) { + val context = LocalContext.current + + val title = context.getString(titleResId) + val subtitle = context.getString(subtitleReId) + + GeneralLargeWidget( + modifier = GlanceModifier + .clickable(onClick = actionRunCallback()), + textColor = contentColor, + backgroundColor = backgroundColor, + titleBarIcon = titleBarIcon, + title = title, + subTitle = subtitle, + mainImageResId = mainImageResId, + bottomContent = { + Row(modifier = GlanceModifier.fillMaxWidth()) { + WidgetIconButton( + modifier = GlanceModifier.defaultWeight(), + text = context.getString(R.string.reading_challenge_widget_search_button), + iconResId = R.drawable.outline_search_24, + action = actionRunCallback() + ) + Spacer(modifier = GlanceModifier.width(16.dp)) + WidgetIconButton( + modifier = GlanceModifier.defaultWeight(), + text = context.getString(R.string.reading_challenge_widget_random_button), + iconResId = R.drawable.ic_dice_24, + action = actionRunCallback() + ) + } + } + ) +} + +@Composable +fun GeneralLargeWidget( + modifier: GlanceModifier = GlanceModifier, + textColor: Color, + backgroundColor: Color, + titleBarIcon: Int = R.drawable.ic_w_logo_shadow, + title: String, + subTitle: String? = null, + mainImageResId: Int, + expandMascot: Boolean = false, + subTitleContent: @Composable () -> Unit = { }, + bottomContent: @Composable () -> Unit = { } +) { + val size = LargeWidgetSize.from(LocalSize.current) + BaseWidgetContent( + color = backgroundColor + ) { + Box( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Layer 1: text column + bottom content (left-aligned, doesn't fight mascot) + Column( + modifier = GlanceModifier.fillMaxSize() + ) { + Row(modifier = GlanceModifier + .defaultWeight() + .fillMaxWidth() + ) { + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = title, + style = TextStyle( + fontSize = size.titleTextSize, + color = ColorProvider(day = textColor, night = textColor), + fontWeight = FontWeight.Medium, + ) + ) + subTitleContent() + Spacer(modifier = GlanceModifier.height(8.dp)) + subTitle?.let { + Text( + text = it, + style = TextStyle( + fontSize = size.subtitleTextSize, + color = ColorProvider(day = textColor, night = textColor), + fontWeight = FontWeight.Medium, + ) + ) + } + } + // Empty spacer column — Reserve for the mascot layer + Spacer(modifier = GlanceModifier.defaultWeight()) + } + bottomContent() + } + // Layer 2: W-logo (top-right) + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.TopEnd + ) { + Image( + provider = ImageProvider(titleBarIcon), + contentDescription = null, + modifier = GlanceModifier.size(size.titleBarIconSize) + ) + } + + // Layer 3: mascot (right side, vertically centered or bottom-aligned) + Box( + modifier = GlanceModifier + .fillMaxSize() + .padding(end = 8.dp), + contentAlignment = Alignment.CenterEnd + ) { + Image( + provider = ImageProvider(mainImageResId), + contentDescription = null, + modifier = GlanceModifier.size(if (expandMascot) size.expandedMascotSize else size.sideMascotSize) + ) + } + } + } +} + +// Loading state +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 368, heightDp = 224) +@Composable +fun LoadingFullPreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.Loading, + enrollmentDate = LocalDate.now() + ) +} + +// NotEnrolled: button + mascot + title + subtitle +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun NotEnrolledLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.NotEnrolled, + enrollmentDate = LocalDate.now() + ) +} + +// NotLiveYet: button + mascot + title + subtitle +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun NotLiveYetLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.NotLiveYet, + enrollmentDate = LocalDate.now() + ) +} + +// EnrolledNotStarted: two icon buttons + mascot + title + subtitle +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun EnrolledNotStartedLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.EnrolledNotStarted, + enrollmentDate = LocalDate.now() + ) +} + +// StreakOngoingReadToday: title row + badge + progress bar + overlay mascot +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun StreakOngoingReadTodayLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.StreakOngoingReadToday(streak = 15), + enrollmentDate = LocalDate.now() + ) +} + +// StreakOngoingNeedsReading: badge + reminder + side mascot + two buttons +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun StreakOngoingNeedsReadingLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.StreakOngoingNeedsReading(streak = 7), + enrollmentDate = LocalDate.now() + ) +} + +// ChallengeCompleted: title + badge + button + expanded mascot +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun ChallengeCompletedLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.ChallengeCompleted, + enrollmentDate = LocalDate.now() + ) +} + +// ChallengeConcludedIncomplete: button-with-icon + expanded mascot +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 320, heightDp = 130) // launcher placed below declared minHeight (rare) +@Preview(widthDp = 320, heightDp = 156) // EXTRA_COMPACT worst-case +@Preview(widthDp = 330, heightDp = 176) // EXTRA_COMPACT worst-case +@Preview(widthDp = 340, heightDp = 200) // COMPACT +@Preview(widthDp = 368, heightDp = 184) // COMPACT, wider +@Preview(widthDp = 368, heightDp = 224) // FULL +@Composable +fun ChallengeConcludedIncompleteLargePreview() { + ReadingChallengeLargeWidgetContent( + state = ReadingChallengeState.ChallengeConcludedIncomplete(streak = 12), + enrollmentDate = LocalDate.now() + ) +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeOnboardingActivity.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeOnboardingActivity.kt new file mode 100644 index 00000000000..1f85834b2d8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeOnboardingActivity.kt @@ -0,0 +1,264 @@ +package org.wikipedia.widgets.readingchallenge + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +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 kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.testkitchen.TestKitchenAdapter +import org.wikipedia.auth.AccountUtil +import org.wikipedia.compose.components.OnboardingItem +import org.wikipedia.compose.components.OnboardingListItem +import org.wikipedia.compose.components.TwoButtonBottomBar +import org.wikipedia.compose.components.WikipediaAlertDialog +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.extensions.instrument +import org.wikipedia.login.LoginActivity +import org.wikipedia.settings.Prefs +import org.wikipedia.theme.Theme +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.UriUtil +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +class ReadingChallengeOnboardingActivity : BaseActivity() { + + private val formatter = DateTimeFormatter.ofPattern("d MMMM", Locale.getDefault()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DeviceUtil.setEdgeToEdge(this) + Prefs.readingChallengeOnboardingShown = true + + _instrument = TestKitchenAdapter.client.getInstrument("apps-widgetchallenge") + .setDefaultActionSource("widget_challenge_announce") + .startFunnel("widget_challenge") + + instrument?.submitInteraction("impression") + + setContent { + + val onboardingItems = listOf( + OnboardingItem( + icon = R.drawable.ic_contract_24dp, + title = R.string.reading_challenge_onboarding_read_title, + subtitleString = getString( + R.string.reading_challenge_onboarding_read_subtitle, + formatter.format(ReadingChallengeWidgetRepository.START_DATE), + formatter.format(ReadingChallengeWidgetRepository.END_DATE) + ) + ), + OnboardingItem( + icon = R.drawable.ic_featured_seasonal_and_gifts_24dp, + title = R.string.reading_challenge_onboarding_win_title, + subTitle = R.string.reading_challenge_onboarding_win_description + ), + OnboardingItem( + icon = R.drawable.dashboard_customize_24dp, + title = R.string.reading_challenge_onboarding_install_title, + subTitle = R.string.reading_challenge_onboarding_install_description + ) + ) + + BaseTheme { + val coroutineScope = rememberCoroutineScope() + var showLoginDialog by remember { mutableStateOf(false) } + if (showLoginDialog) { + WikipediaAlertDialog( + title = stringResource(R.string.reading_challenge_onboarding_prompt_title), + message = stringResource(R.string.reading_challenge_onboarding_prompt_message), + confirmButtonText = stringResource(R.string.reading_challenge_onboarding_prompt_login), + dismissButtonText = stringResource(R.string.reading_challenge_onboarding_prompt_no_thanks), + dismissButtonColor = WikipediaTheme.colors.secondaryColor, + onDismissRequest = { + showLoginDialog = false + }, + onConfirmButtonClick = { + instrument?.submitInteraction(action = "click", actionSource = "widget_challenge_login", elementId = "login_join") + startActivity(LoginActivity.newIntent(this, LoginActivity.SOURCE_READING_CHALLENGE)) + finish() + }, + onDismissButtonClick = { + instrument?.submitInteraction(action = "click", actionSource = "widget_challenge_login", elementId = "no_thanks") + finish() + } + ) + } + + OnboardingScreen( + modifier = Modifier.fillMaxSize(), + onboardingItems = onboardingItems, + onCloseClick = { + finish() + }, + onLearnMoreClick = { + instrument?.submitInteraction(action = "click", elementId = "learn_more") + UriUtil.visitInExternalBrowser(context = this, uri = getString(R.string.reading_challenge_learn_more).toUri()) + }, + onJoinClick = { + instrument?.submitInteraction(action = "click", elementId = "join_challenge") + if (!AccountUtil.isLoggedIn) { + showLoginDialog = true + } else { + Prefs.readingChallengeEnrolled = true + Prefs.readingChallengeEnrollmentDate = LocalDate.now().toString() + coroutineScope.launch { + ReadingChallengeWidgetRepository(this@ReadingChallengeOnboardingActivity).updateWidgetsAndSendAnalytics() + finish() + } + } + } + ) + } + } + } + + @Composable + fun OnboardingScreen( + modifier: Modifier = Modifier, + onboardingItems: List, + onCloseClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onJoinClick: () -> Unit + ) { + Scaffold( + modifier = modifier + .safeDrawingPadding(), + containerColor = WikipediaTheme.colors.paperColor, + bottomBar = { + TwoButtonBottomBar( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + primaryButtonText = stringResource(R.string.reading_challenge_onboarding_join_button), + secondaryButtonText = stringResource(R.string.reading_challenge_onboarding_learn_more_button), + onPrimaryOnClick = onJoinClick, + onSecondaryOnClick = onLearnMoreClick + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + IconButton( + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 12.dp) + .offset(x = 12.dp), + onClick = { + onCloseClick() + } + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_close_black_24dp), + contentDescription = stringResource(R.string.dialog_close_description), + tint = WikipediaTheme.colors.primaryColor + ) + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.reading_challenge_onboarding_header), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + + onboardingItems.forEach { onboardingItem -> + OnboardingListItem( + modifier = Modifier.padding(bottom = 16.dp), + item = onboardingItem + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Text( + modifier = Modifier + .fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.reading_challenge_onboarding_note), + style = MaterialTheme.typography.bodySmall, + color = WikipediaTheme.colors.placeholderColor + ) + } + } + } + + @Preview + @Composable + private fun OnboardingScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + OnboardingScreen( + onboardingItems = listOf( + OnboardingItem( + icon = R.drawable.ic_contract_24dp, + title = R.string.reading_challenge_onboarding_read_title, + subTitle = R.string.reading_challenge_onboarding_read_subtitle + ), + OnboardingItem( + icon = R.drawable.ic_featured_seasonal_and_gifts_24dp, + title = R.string.reading_challenge_onboarding_win_title, + subTitle = R.string.reading_challenge_onboarding_win_description + ), + OnboardingItem( + icon = R.drawable.dashboard_customize_24dp, + title = R.string.reading_challenge_onboarding_install_title, + subTitle = R.string.reading_challenge_onboarding_install_description + ) + ), + onCloseClick = {}, + onLearnMoreClick = {}, + onJoinClick = {} + ) + } + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, ReadingChallengeOnboardingActivity::class.java) + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeRewardDialog.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeRewardDialog.kt new file mode 100644 index 00000000000..9606fedc612 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeRewardDialog.kt @@ -0,0 +1,183 @@ +package org.wikipedia.widgets.readingchallenge + +import android.os.Bundle +import android.view.LayoutInflater +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.layout.ContentScale +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import org.wikipedia.R +import org.wikipedia.analytics.testkitchen.TestKitchenAdapter +import org.wikipedia.compose.components.AppButton +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.theme.Theme +import org.wikipedia.util.UriUtil + +class ReadingChallengeRewardDialog : ExtendedBottomSheetDialogFragment(startExpanded = true) { + private val instrument = TestKitchenAdapter.client.getInstrument("apps-widgetchallenge") + .startFunnel("widget_challenge") + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + instrument.submitInteraction( + action = "impression", + actionSource = "challenge_complete", + elementId = "collect_prize", + actionContext = mapOf("streak_count" to Prefs.readingChallengeStreak) + ) + return ComposeView(requireContext()).apply { + setContent { + BaseTheme { + RewardScreen( + onCloseClick = { + dismiss() + }, + onNavigateClick = { + instrument.submitInteraction( + action = "click", + actionSource = "challenge_complete", + elementId = "store_button", + actionContext = mapOf("streak_count" to Prefs.readingChallengeStreak) + ) + UriUtil.visitInExternalBrowser(requireContext(), getString(R.string.reading_challenge_reward_url).toUri()) + dismiss() + } + ) + } + } + } + } +} + +@Composable +fun RewardScreen( + onCloseClick: () -> Unit, + onNavigateClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Box( + modifier = Modifier + .width(32.dp) + .height(4.dp) + .align(Alignment.CenterHorizontally) + .clip(RoundedCornerShape(50)) + .background(WikipediaTheme.colors.placeholderColor) + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.reading_challenge_widget_collect_your_prize_button), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + IconButton( + modifier = Modifier + .offset(x = 12.dp), + onClick = { + onCloseClick() + } + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_close_black_24dp), + contentDescription = stringResource(R.string.dialog_close_description), + tint = WikipediaTheme.colors.primaryColor + ) + } + } + + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .height(202.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + ) { + Image( + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.FillWidth, + painter = painterResource(id = R.drawable.reading_challenge_reward), + contentDescription = null + ) + } + + Text( + modifier = Modifier, + text = stringResource(R.string.reading_challenge_widget_reward_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier.fillMaxWidth(), + // The "emptyArray" argument is necessary to properly parse a string resource with + // a double-escaped percent sign (%%), which was necessary for older style + // Context.getString() calls. In the future, for usages only in stringResource() + // calls, this argument can be removed, and percents don't need to be escaped. + text = stringResource(R.string.reading_challenge_widget_reward_body, *emptyArray()), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.secondaryColor + ) + AppButton( + modifier = Modifier + .padding(top = 16.dp) + .align(Alignment.End), + onClick = onNavigateClick + ) { + Text( + stringResource(R.string.reading_challenge_widget_reward_button_label, *emptyArray()) + ) + } + } +} + +@Preview +@Composable +private fun RewardScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + RewardScreen( + onCloseClick = {}, + onNavigateClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeSmallWidgetContent.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeSmallWidgetContent.kt new file mode 100644 index 00000000000..beee82b3f86 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeSmallWidgetContent.kt @@ -0,0 +1,333 @@ +package org.wikipedia.widgets.readingchallenge + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import org.wikipedia.R +import org.wikipedia.widgets.readingchallenge.WidgetCombinations.forToday +import java.time.LocalDate + +@Composable +fun ReadingChallengeSmallWidgetContent( + state: ReadingChallengeState, + enrollmentDate: LocalDate +) { + val context = LocalContext.current + when (state) { + ReadingChallengeState.Loading -> { + ReadingChallengeWidgetLoading( + modifier = GlanceModifier + .fillMaxSize() + ) + } + + ReadingChallengeState.ChallengeCompleted -> { + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak_final, + ReadingChallengeWidgetRepository.READING_STREAK_GOAL, ReadingChallengeWidgetRepository.READING_STREAK_GOAL, ReadingChallengeWidgetRepository.READING_STREAK_GOAL) + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.challengeCompletedBackground, + mainImageResId = R.drawable.wp25_babyglobe_celebration_neutral, + usCompactMascotSize = true, + bottomContent = { + WidgetBadge( + text = streakText, + textSize = 16.sp, + iconResId = R.drawable.ic_flame_24dp, + iconSize = 24.dp, + iconTintColor = WidgetColors.primary, + textColor = WidgetColors.primary + ) + Spacer( + modifier = GlanceModifier.height(8.dp) + ) + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_collect_prize_button), + action = actionRunCallback() + ) + } + ) + } + is ReadingChallengeState.ChallengeConcludedIncomplete -> { + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak_final, + ReadingChallengeWidgetRepository.READING_STREAK_GOAL, state.streak, ReadingChallengeWidgetRepository.READING_STREAK_GOAL) + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.challengeCompletedBackground, + mainImageResId = R.drawable.wp25_babyglobe_reading, + bottomContent = { + WidgetButton( + text = streakText, + action = actionRunCallback(), + backgroundColor = WidgetColors.challengeConcludedIncompleteButtonBackground, + contentColor = WidgetColors.primary, + icon = ImageProvider(R.drawable.ic_flame_24dp) + ) + } + ) + } + ReadingChallengeState.ChallengeConcludedNoStreak, ReadingChallengeState.ChallengeRemoved -> { + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + backgroundColor = WidgetColors.challengeCompletedBackground, + mainImageResId = R.drawable.wp25_babyglobe_reading + ) + } + ReadingChallengeState.EnrolledNotStarted -> { + val combination = WidgetCombinations.enrolledNotStarted.forToday(enrollmentDate = enrollmentDate) + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + mainImageResId = combination.iconResId, + backgroundColor = combination.backgroundColor, + bottomContent = { + WidgetButton( + text = context.getString(R.string.feed), + action = actionRunCallback() + ) + } + ) + } + ReadingChallengeState.NotEnrolled -> { + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + mainImageResId = R.drawable.wp25_babyglobe_reading, + backgroundColor = WidgetColors.joinChallengeBackground, + bottomContent = { + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_join_challenge_button), + action = actionRunCallback() + ) + } + ) + } + ReadingChallengeState.NotLiveYet -> { + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + mainImageResId = R.drawable.wp25_babyglobe_reading, + backgroundColor = WidgetColors.challengeNotLiveBackground, + bottomContent = { + WidgetButton( + text = context.getString(R.string.reading_challenge_widget_explore_button), + action = actionRunCallback() + ) + } + ) + } + is ReadingChallengeState.StreakOngoingNeedsReading -> { + val combination = WidgetCombinations.streakNeedsReading.forToday(enrollmentDate = enrollmentDate) + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak, state.streak, state.streak) + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable(onClick = actionRunCallback()), + backgroundColor = combination.backgroundColor, + mainImageResId = combination.iconResId, + bottomContent = { + val size = SmallWidgetSize.from(LocalSize.current) + WidgetBadge( + text = streakText, + iconResId = R.drawable.ic_flame_24dp, + iconSize = size.badgeIconSize, + textSize = size.badgeTextSize, + iconTintColor = combination.contentColor, + textColor = combination.contentColor + ) + } + ) + } + is ReadingChallengeState.StreakOngoingReadToday -> { + val combination = WidgetCombinations.streakOngoing.forToday(enrollmentDate = enrollmentDate) + val streakText = context.resources.getQuantityString(R.plurals.reading_challenge_small_widget_streak, state.streak, state.streak) + SmallWidget( + modifier = GlanceModifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 16.dp) + .clickable( + onClick = actionRunCallback() + ), + backgroundColor = combination.backgroundColor, + mainImageResId = combination.iconResId, + bottomContent = { + val size = SmallWidgetSize.from(LocalSize.current) + WidgetBadge( + text = streakText, + iconResId = R.drawable.ic_flame_24dp, + iconSize = size.badgeIconSize, + textSize = size.badgeTextSize, + iconTintColor = combination.contentColor, + textColor = combination.contentColor + ) + } + ) + } + } +} + +@Composable +fun SmallWidget( + modifier: GlanceModifier = GlanceModifier, + titleBarIcon: Int = R.drawable.ic_w_logo_shadow, + mainImageResId: Int, + backgroundColor: Color, + usCompactMascotSize: Boolean = false, + bottomContent: @Composable () -> Unit = { } +) { + val size = SmallWidgetSize.from(LocalSize.current) + BaseWidgetContent( + color = backgroundColor + ) { + Box( + modifier = modifier, + ) { + Row( + modifier = GlanceModifier + .fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + Image( + provider = ImageProvider(titleBarIcon), + contentDescription = null, + modifier = GlanceModifier.size(size.titleBarIconSize) + ) + } + + Column( + modifier = GlanceModifier + .fillMaxSize() + .padding(top = size.paddingBetweenMascotAndTitleIcon), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = GlanceModifier.defaultWeight()) + + Image( + provider = ImageProvider(mainImageResId), + contentDescription = null, + modifier = GlanceModifier.size(if (usCompactMascotSize) size.compactMascotSize else size.mascotSize) + ) + + Spacer(modifier = GlanceModifier.defaultWeight()) + + bottomContent() + } + } + } +} + +// ChallengeCompleted: layout has (mascot + badge + spacer + button) +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 184, heightDp = 130) // TINY +@Preview(widthDp = 200, heightDp = 141) // boundary: first EXTRA_COMPACT +@Preview(widthDp = 184, heightDp = 160) // EXTRA_COMPACT +@Preview(widthDp = 176, heightDp = 176) // boundary: last EXTRA_COMPACT +@Preview(widthDp = 180, heightDp = 177) // boundary: first COMPACT +@Preview(widthDp = 120, heightDp = 200) // COMPACT +@Preview(widthDp = 230, heightDp = 230) // boundary: last COMPACT +@Preview(widthDp = 230, heightDp = 231) // boundary: first FULL +@Preview(widthDp = 200, heightDp = 280) // FULL +@Composable +fun SmallWidgetChallengeCompletedTierBoundariesPreview() { + ReadingChallengeSmallWidgetContent( + state = ReadingChallengeState.ChallengeCompleted, + enrollmentDate = LocalDate.now() + ) +} + +// StreakOngoingReadToday: mascot + badge +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 184, heightDp = 130) +@Preview(widthDp = 130, heightDp = 200) +@Preview(widthDp = 184, heightDp = 160) +@Preview(widthDp = 200, heightDp = 200) +@Preview(widthDp = 250, heightDp = 250) +@Composable +fun SmallWidgetStreakOngoingReadTodayTierBoundariesPreview() { + ReadingChallengeSmallWidgetContent( + state = ReadingChallengeState.StreakOngoingReadToday(streak = 15), + enrollmentDate = LocalDate.now() + ) +} + +// ChallengeConcludedIncomplete: button-with-icon (longest button text) +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 184, heightDp = 130) +@Preview(widthDp = 130, heightDp = 200) +@Preview(widthDp = 184, heightDp = 160) +@Preview(widthDp = 200, heightDp = 200) +@Preview(widthDp = 250, heightDp = 250) +@Composable +fun SmallWidgetChallengeIncompleteTierBoundariesPreview() { + ReadingChallengeSmallWidgetContent( + state = ReadingChallengeState.ChallengeConcludedIncomplete(streak = 12), + enrollmentDate = LocalDate.now() + ) +} + +// NotEnrolled: button + mascot, simpler layout but worth checking +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 184, heightDp = 130) +@Preview(widthDp = 130, heightDp = 200) +@Preview(widthDp = 184, heightDp = 160) +@Preview(widthDp = 200, heightDp = 200) +@Preview(widthDp = 250, heightDp = 250) +@Composable +fun SmallWidgetNotEnrolledTierBoundariesPreview() { + ReadingChallengeSmallWidgetContent( + state = ReadingChallengeState.NotEnrolled, + enrollmentDate = LocalDate.now() + ) +} + +// EnrolledNotStarted: button + mascot, similar density to NotEnrolled +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 184, heightDp = 130) +@Preview(widthDp = 130, heightDp = 200) +@Preview(widthDp = 184, heightDp = 160) +@Preview(widthDp = 200, heightDp = 200) +@Preview(widthDp = 250, heightDp = 250) +@Composable +fun SmallWidgetEnrolledNotStartedTierBoundariesPreview() { + ReadingChallengeSmallWidgetContent( + state = ReadingChallengeState.EnrolledNotStarted, + enrollmentDate = LocalDate.now() + ) +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeState.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeState.kt new file mode 100644 index 00000000000..dbfd4d1d121 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeState.kt @@ -0,0 +1,41 @@ +package org.wikipedia.widgets.readingchallenge + +import java.time.LocalDate + +sealed interface ReadingChallengeState { + object Loading : ReadingChallengeState + // State 1: Pre-enrollment before May 1, 2026 + object NotLiveYet : ReadingChallengeState + + // State 2: Challenge Active (May 1 to May 31, 2026) + + // User has not joined + object NotEnrolled : ReadingChallengeState + + // Joined && article not opened + object EnrolledNotStarted : ReadingChallengeState + + // Joined && streak of 1+ days && no article opened + data class StreakOngoingNeedsReading(val streak: Int) : ReadingChallengeState + + // Joined && streak of 1+ days && article opened + data class StreakOngoingReadToday(val streak: Int) : ReadingChallengeState + + // State 3: Success + object ChallengeCompleted : ReadingChallengeState + + // State 4: Post Challenge (After May 31, 2026) + data class ChallengeConcludedIncomplete(val streak: Int) : ReadingChallengeState // Joined && did not dit 25 streak + + object ChallengeConcludedNoStreak : ReadingChallengeState // Joined && 0 streak + + // State 5: Challenge Remove + object ChallengeRemoved : ReadingChallengeState +} + +data class ReadingChallengeUserData( + val currentDate: LocalDate, + val enabled: Boolean, + val currentStreak: Int, + val hasReadToday: Boolean +) diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidget.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidget.kt new file mode 100644 index 00000000000..8a07f159346 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidget.kt @@ -0,0 +1,348 @@ +package org.wikipedia.widgets.readingchallenge + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ButtonDefaults +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.FilledButton +import androidx.glance.appwidget.provideContent +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.wikipedia.R +import org.wikipedia.settings.Prefs +import java.time.LocalDate + +class ReadingChallengeWidget : GlanceAppWidget() { + companion object { + private val fullWidthThreshold = 320.dp + } + + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance( + context: Context, + id: GlanceId + ) { + val repository = ReadingChallengeWidgetRepository(context) + + provideContent { + val state by repository.observeState().collectAsState(initial = ReadingChallengeState.Loading) + + GlanceTheme { + val size = LocalSize.current + val enrollmentDate = if (Prefs.readingChallengeEnrollmentDate.isNotEmpty()) LocalDate.parse(Prefs.readingChallengeEnrollmentDate) else LocalDate.now() + + if (size.width >= fullWidthThreshold) { + ReadingChallengeLargeWidgetContent(state, enrollmentDate) + } else { + ReadingChallengeSmallWidgetContent(state, enrollmentDate) + } + } + } + } +} + +/** + * Should be used as the base wrapper for all content in the widget to ensure a consistent corner radius background + * across all api level + */ +@Composable +fun BaseWidgetContent( + color: Color, + content: @Composable () -> Unit +) { + Box( + modifier = GlanceModifier + .fillMaxSize() + ) { + // base background color for widget + Image( + provider = ImageProvider(R.drawable.widget_shape_background), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + colorFilter = ColorFilter.tint( + ColorProvider(day = color, night = color) + ) + ) + content() + } +} + +@Composable +fun WidgetButton( + text: String, + action: Action, + backgroundColor: Color = WidgetColors.progressive, + contentColor: Color = WidgetColors.white, + icon: ImageProvider? = null, + modifier: GlanceModifier = GlanceModifier.fillMaxWidth() +) { + FilledButton( + text = text, + onClick = action, + colors = ButtonDefaults.buttonColors( + backgroundColor = ColorProvider(day = backgroundColor, night = backgroundColor), + contentColor = ColorProvider(day = contentColor, night = contentColor) + ), + icon = icon, + modifier = modifier + ) +} + +@Composable +fun WidgetBadge( + text: String, + textSize: TextUnit = 32.sp, + iconResId: Int, + iconSize: Dp = 16.dp, + spacerWidth: Dp = 4.dp, + iconTintColor: Color, + textColor: Color, + modifier: GlanceModifier = GlanceModifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + provider = ImageProvider(iconResId), + contentDescription = null, + modifier = GlanceModifier + .size(iconSize), + colorFilter = ColorFilter.tint(ColorProvider(day = iconTintColor, night = iconTintColor)) + ) + Spacer( + modifier = GlanceModifier.width(spacerWidth) + ) + Text( + text = text, + style = TextStyle( + fontSize = textSize, + color = ColorProvider(day = textColor, night = textColor), + fontWeight = FontWeight.Medium + ) + ) + } +} + +@Composable +fun WidgetIconButton( + text: String, + action: Action, + iconResId: Int, + modifier: GlanceModifier = GlanceModifier +) { + FilledButton( + text = text, + onClick = action, + icon = ImageProvider(iconResId), + colors = ButtonDefaults.buttonColors( + backgroundColor = ColorProvider(day = WidgetColors.progressive, night = WidgetColors.progressive), + contentColor = ColorProvider(day = WidgetColors.white, night = WidgetColors.white) + ), + modifier = modifier + ) +} + +@Composable +fun ReadingChallengeWidgetLoading( + modifier: GlanceModifier = GlanceModifier, + backgroundColorResId: Int = WidgetBackground.challengeNotOptInRadialGradient +) { + Box( + modifier = modifier + ) { + // base background color for widget + Image( + provider = ImageProvider(backgroundColorResId), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize() + ) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.End + ) { + Image( + provider = ImageProvider(R.drawable.ic_w_logo_shadow), + contentDescription = null, + modifier = GlanceModifier.size(36.dp) + ) + } + } +} + +enum class SmallWidgetSize { + TINY, + EXTRA_COMPACT, + COMPACT, + FULL; + + val mascotSize: Dp + get() = when (this) { + TINY -> 46.dp + EXTRA_COMPACT -> 56.dp + COMPACT -> 80.dp + FULL -> 120.dp + } + + val compactMascotSize: Dp + get() = when (this) { + TINY -> 36.dp + EXTRA_COMPACT -> 46.dp + COMPACT -> 70.dp + FULL -> 120.dp + } + + val badgeTextSize: TextUnit + get() = when (this) { + TINY -> 18.sp + EXTRA_COMPACT -> 22.sp + COMPACT -> 26.sp + FULL -> 32.sp + } + + val badgeIconSize: Dp + get() = when (this) { + TINY -> 22.dp + EXTRA_COMPACT -> 28.dp + COMPACT -> 36.dp + FULL -> 40.dp + } + + val paddingBetweenMascotAndTitleIcon: Dp + get() = when (this) { + TINY, EXTRA_COMPACT -> 0.dp + COMPACT, FULL -> 16.dp + } + + val titleBarIconSize: Dp + get() = when (this) { + TINY -> 22.dp + EXTRA_COMPACT -> 28.dp + COMPACT -> 32.dp + FULL -> 36.dp + } + + companion object { + fun from(size: DpSize): SmallWidgetSize { + return when { + size.height <= 140.dp -> TINY + size.height <= 176.dp -> EXTRA_COMPACT + size.height <= 230.dp -> COMPACT + else -> FULL + } + } + } +} + +enum class LargeWidgetSize { + TINY, + EXTRA_COMPACT, + COMPACT, + FULL; + + val titleTextSize: TextUnit + get() = when (this) { + TINY -> 16.sp + EXTRA_COMPACT -> 18.sp + COMPACT -> 24.sp + FULL -> 32.sp + } + + val subtitleTextSize: TextUnit + get() = when (this) { + TINY -> 12.sp + EXTRA_COMPACT, COMPACT -> 14.sp + FULL -> 16.sp + } + + val streakBadgeTextSize: TextUnit + get() = when (this) { + TINY -> 18.sp + EXTRA_COMPACT -> 20.sp + COMPACT -> 26.sp + FULL -> 32.sp + } + + val streakBadgeIconSize: Dp + get() = when (this) { + TINY -> 22.dp + EXTRA_COMPACT -> 28.dp + COMPACT -> 36.dp + FULL -> 40.dp + } + + val titleBarIconSize: Dp + get() = when (this) { + TINY -> 22.dp + EXTRA_COMPACT -> 24.dp + COMPACT -> 32.dp + FULL -> 36.dp + } + + // Mascot size when bottom content is not empty. + val sideMascotSize: Dp + get() = when (this) { + TINY -> 28.dp + EXTRA_COMPACT -> 40.dp + COMPACT -> 70.dp + FULL -> 100.dp + } + + // Mascot size when bottom content is empty/spacer-only. Can expand down further. + val expandedMascotSize: Dp + get() = when (this) { + TINY -> 48.dp + EXTRA_COMPACT -> 64.dp + COMPACT -> 95.dp + FULL -> 120.dp + } + + val overlayMascotSize: Dp + get() = when (this) { + TINY -> 44.dp + EXTRA_COMPACT -> 60.dp + COMPACT -> 85.dp + FULL -> 110.dp + } + + companion object { + fun from(size: DpSize): LargeWidgetSize = when { + size.height <= 140.dp -> TINY + size.height <= 176.dp -> EXTRA_COMPACT + size.height <= 230.dp -> COMPACT + else -> FULL + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetReceiver.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetReceiver.kt new file mode 100644 index 00000000000..b21e3fbe207 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetReceiver.kt @@ -0,0 +1,25 @@ +package org.wikipedia.widgets.readingchallenge + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import org.wikipedia.settings.Prefs + +class ReadingChallengeWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget + get() = ReadingChallengeWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + // If the user has already added the widget, we should not display the install dialog + Prefs.readingChallengeInstallPromptShown = true + ReadingChallengeWidgetWorker.scheduleNextWidgetUpdate(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + Prefs.readingChallengeInstallPromptShown = false + ReadingChallengeWidgetWorker.cancelScheduledUpdates(context) + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetRepository.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetRepository.kt new file mode 100644 index 00000000000..deefcedfca5 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetRepository.kt @@ -0,0 +1,177 @@ +package org.wikipedia.widgets.readingchallenge + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.glance.appwidget.updateAll +import androidx.preference.PreferenceManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.auth.AccountUtil +import org.wikipedia.settings.Prefs +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +class ReadingChallengeWidgetRepository(private val context: Context) { + fun observeState(): Flow { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val relevantKeys = setOf( + context.getString(R.string.preference_key_reading_challenge_streak), + context.getString(R.string.preference_key_reading_challenge_enrolled), + context.getString(R.string.preference_key_reading_challenge_last_read_date) + ) + return callbackFlow { + trySend(ReadingChallengeState.Loading) // initial loading state + fun emit() { + trySend(getCurrentState()) + } + + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in relevantKeys) { + emit() + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + emit() // for daily updates and to emit initial value when flow is collected + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() + } + + fun hasReadToday(currentDate: LocalDate): Boolean { + Prefs.readingChallengeLastReadDate.let { + return if (it.isNotEmpty()) { + LocalDate.parse(it) == currentDate + } else { + false + } + } + } + + fun resolveState(userData: ReadingChallengeUserData): ReadingChallengeState { + // Stage 5: Remove challenge + if (userData.currentDate.isAfter(REMOVE_DATE)) { + return ReadingChallengeState.ChallengeRemoved + } + + // Stage 1: Pre-enrollment + if (userData.currentDate.isBefore(START_DATE)) { + return ReadingChallengeState.NotLiveYet + } + + // From this point onward, we are in the active or past the challenge period + + // Stage 2: Challenge is active and user has not enrolled + if (!userData.enabled) { + return ReadingChallengeState.NotEnrolled + } + + // From this point onward, user is enrolled + + // Stage 3: Success State, once user is enrolled we check immediately to bypass other conditions if they finish on time + if (userData.currentStreak >= READING_STREAK_GOAL) { + return ReadingChallengeState.ChallengeCompleted + } + + // Stage 4: Post Challenge (After May 31, 2026) + if (userData.currentDate.isAfter(END_DATE)) { + return if (userData.currentStreak > 0) { + ReadingChallengeState.ChallengeConcludedIncomplete(userData.currentStreak) // streak did not hit 25, but they did have a streak + } else { + ReadingChallengeState.ChallengeConcludedNoStreak // no streak at all + } + } + + // Stage 2: no article opened + if (userData.currentStreak == 0) { + return ReadingChallengeState.EnrolledNotStarted + } + + // Stage 2: Active streak + return if (userData.hasReadToday) { + ReadingChallengeState.StreakOngoingReadToday(userData.currentStreak) + } else { + ReadingChallengeState.StreakOngoingNeedsReading(userData.currentStreak) + } + } + + private fun getCurrentState(currentDate: LocalDate = LocalDate.now()): ReadingChallengeState { + recalculateStreakIfNeeded(currentDate) + return resolveState( + ReadingChallengeUserData( + currentDate = currentDate, + enabled = Prefs.readingChallengeEnrolled, + currentStreak = Prefs.readingChallengeStreak, + hasReadToday = hasReadToday(currentDate) + ) + ) + } + + fun recalculateStreakIfNeeded(currentDate: LocalDate) { + if (currentDate.isAfter(END_DATE)) return // will not reset after challenge ends + + val lastReadDateStr = Prefs.readingChallengeLastReadDate + if (lastReadDateStr.isNotEmpty()) { + val lastReadDate = LocalDate.parse(lastReadDateStr) + val daysBetween = ChronoUnit.DAYS.between(lastReadDate, currentDate) + if (daysBetween > 1) { + Prefs.readingChallengeStreak = 0 + } + } + } + + suspend fun updateWidgetsAndSendAnalytics() { + ReadingChallengeAnalyticsHelper.sendHeartbeatEvent(getCurrentState()) + ReadingChallengeWidget().updateAll(context) + } + suspend fun updateOnArticleRead(currentDate: LocalDate) { + if (currentDate.isBefore(START_DATE) || currentDate.isAfter(END_DATE)) { + return + } + + if (Prefs.readingChallengeEnrolled && !hasReadToday(currentDate)) { + Prefs.readingChallengeLastReadDate = currentDate.toString() + Prefs.readingChallengeStreak += 1 + updateWidgetsAndSendAnalytics() + } + } + + companion object { + const val READING_STREAK_GOAL = 25 + const val INTENT_EXTRA_READING_CHALLENGE_REWARD = "reading_challenge_reward" + const val READING_CHALLENGE_END_DATE = "2026-06-18" + const val READING_CHALLENGE_START_DATE = "2026-05-11" + val START_DATE get() = LocalDate.parse(Prefs.readingChallengeStartDate.ifEmpty { READING_CHALLENGE_START_DATE }) + val END_DATE get() = LocalDate.parse(Prefs.readingChallengeEndDate.ifEmpty { READING_CHALLENGE_END_DATE }) + private val REMOVE_DATE = LocalDate.of(2026, 7, 27) + + private val isChallengeActive: Boolean + get() = !LocalDate.now().isBefore(START_DATE) && LocalDate.now().isBefore(END_DATE) + + fun isWidgetInstalled(): Boolean { + val context = WikipediaApp.instance + val ids = AppWidgetManager.getInstance(context).getAppWidgetIds( + ComponentName(context, ReadingChallengeWidgetReceiver::class.java) + ) + return ids.isNotEmpty() + } + + fun shouldShowOnboardingDialog(): Boolean { + return !Prefs.readingChallengeOnboardingShown && isChallengeActive + } + + fun shouldShowWidgetInstallDialog(): Boolean { + return Prefs.readingChallengeOnboardingShown && !Prefs.readingChallengeInstallPromptShown && + Prefs.readingChallengeEnrolled && AccountUtil.isLoggedIn && isChallengeActive && !isWidgetInstalled() + } + + fun shouldShowReward(intent: Intent): Boolean { + return intent.hasExtra(INTENT_EXTRA_READING_CHALLENGE_REWARD) && AccountUtil.isLoggedIn + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetWorker.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetWorker.kt new file mode 100644 index 00000000000..28d4578c227 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/ReadingChallengeWidgetWorker.kt @@ -0,0 +1,61 @@ +package org.wikipedia.widgets.readingchallenge + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.WorkerParameters +import org.wikipedia.settings.Prefs +import org.wikipedia.util.ReleaseUtil +import java.time.Duration +import java.time.LocalDateTime +import java.time.LocalTime +import java.util.concurrent.TimeUnit + +class ReadingChallengeWidgetWorker( + context: Context, + workerParameters: WorkerParameters +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + ReadingChallengeWidgetRepository(applicationContext).updateWidgetsAndSendAnalytics() + scheduleNextWidgetUpdate(applicationContext) + return Result.success() + } + + companion object { + const val WORK_NAME = "ReadingChallengeWidgetWorker" + + fun scheduleNextWidgetUpdate(context: Context) { + val delay = if (ReleaseUtil.isPreBetaRelease && Prefs.readingChallengeWidgetFastCycle) { + Duration.ofMinutes(1) + } else { + val now = LocalDateTime.now() + val nextMidnight = LocalDateTime.of(now.toLocalDate().plusDays(1), LocalTime.MIDNIGHT).plusMinutes(1) + Duration.between(now, nextMidnight) + } + val workRequest = OneTimeWorkRequest.Builder(ReadingChallengeWidgetWorker::class.java) + .addTag(ReadingChallengeWidgetWorker::class.java.simpleName) + .setInitialDelay(delay.toMillis(), TimeUnit.MILLISECONDS) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + workRequest + ) + } + + fun cancelScheduledUpdates(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/StreakProgressBar.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/StreakProgressBar.kt new file mode 100644 index 00000000000..b948e7868d5 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/StreakProgressBar.kt @@ -0,0 +1,176 @@ +package org.wikipedia.widgets.readingchallenge + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import org.wikipedia.R + +@Composable +fun StreakProgressBar( + currentStreak: Int, + totalDays: Int, + dynamicWidth: Dp, + modifier: GlanceModifier = GlanceModifier, + progressColor: Color, + progressBarColor: Color +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + if (currentStreak !in 1..= totalDays) progressColor else progressBarColor) + .cornerRadius(12.dp) + ) { } + } else { + // progressed math + val progressPercent = (currentStreak.toFloat() / totalDays.toFloat()).coerceIn(0f, 1f) + + // the max allowable width for the completed segment minus the dot and space + val completedWidth = dynamicWidth * progressPercent + + // Completed portion + Box( + modifier = GlanceModifier + .height(24.dp) + .width(completedWidth) + ) { + Image( + provider = ImageProvider(R.drawable.progress_bar_start_bg), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + colorFilter = ColorFilter.tint(ColorProvider(day = progressColor, night = progressColor)) + ) + } + + Spacer(modifier = GlanceModifier.width(4.dp)) + + // Current position dot + Box( + modifier = GlanceModifier + .size(24.dp) + ) { + Image( + provider = ImageProvider(R.drawable.widget_shape_12dp_corner_radius), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + colorFilter = ColorFilter.tint(ColorProvider(day = progressColor, night = progressColor)) + ) + } + + Spacer(modifier = GlanceModifier.width(4.dp)) + + // Remaining portion + val remainingWidth = (dynamicWidth - completedWidth).coerceAtLeast(0.dp) + Box( + modifier = GlanceModifier + .height(24.dp) + .width(remainingWidth) + ) { + Image( + provider = ImageProvider(R.drawable.progress_bar_end_bg), + contentDescription = null, + modifier = GlanceModifier.fillMaxSize(), + colorFilter = ColorFilter.tint(ColorProvider(day = progressBarColor, night = progressBarColor)) + ) + } + } + } +} + +@Composable +fun BoxedStreakProgressBar( + modifier: GlanceModifier = GlanceModifier, + currentStreak: Int, + totalDays: Int, + startIconResId: Int, + backgroundColor: Color, + endIconResId: Int, + progressColor: Color, + progressBarColor: Color +) { + Box { + // base background color for widget + Image( + provider = ImageProvider(R.drawable.widget_shape_inner), + contentDescription = null, + modifier = GlanceModifier + .fillMaxWidth() + .height(48.dp), + colorFilter = ColorFilter.tint( + ColorProvider(day = backgroundColor, night = backgroundColor) + ) + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // Calendar icon 1 + Image( + provider = ImageProvider(startIconResId), + contentDescription = null, + modifier = GlanceModifier.size(24.dp), + colorFilter = ColorFilter.tint( + ColorProvider( + day = Color.White, + night = Color.White + ) + ) + ) + + Spacer(modifier = GlanceModifier.width(12.dp)) + + // Calculate precise DP width depending on current widget boundaries + // TODO: optimization finding a better way to calculate this instead of manual summation of all the fixed widths and spacers + val paddingBuffer = 176.dp + val dynamicWidth = (LocalSize.current.width - paddingBuffer).coerceAtLeast(0.dp) + + StreakProgressBar( + currentStreak = currentStreak, + totalDays = totalDays, + dynamicWidth = dynamicWidth, + progressColor = progressColor, + progressBarColor = progressBarColor + ) + + Spacer(modifier = GlanceModifier.width(12.dp)) + + // Calendar icon 25 + Image( + provider = ImageProvider(endIconResId), + contentDescription = null, + modifier = GlanceModifier.size(24.dp), + colorFilter = ColorFilter.tint( + ColorProvider( + day = Color.White, + night = Color.White + ) + ) + ) + } + } +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetColors.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetColors.kt new file mode 100644 index 00000000000..50d12d3abc7 --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetColors.kt @@ -0,0 +1,37 @@ +package org.wikipedia.widgets.readingchallenge + +import androidx.compose.ui.graphics.Color +import org.wikipedia.R + +object WidgetColors { + val challengeNotOptInBackground = Color(0xFFFFE49C) + + val phoneReadingBackground = Color(0xFFF5EBF2) + val phoneReadingContent = Color(0xFF9B527F) + val phoneReadingProgressColor = Color(0xFFC690B4) + + val musicReadingBackground = Color(0xFFE6E0F0) + val musicContent = Color(0xFF534FA3) + val musicReadingProgressColor = Color(0xFFC5B9DD) + + val spaceReadingBackground = Color(0xFFD9E2FF) + val spaceContent = Color(0xFF3056A9) + val spaceReadingProgressColor = Color(0xFFA6BBF5) + + val challengeCompletedBackground = Color(0xFFB6D4FB) + val challengeConcludedIncompleteButtonBackground = Color(0xFFFFCC33) + + val streakOngoingNeedsReadingBackground = Color(0xFFFFEAD4) + val streakOngoingNeedsReadingContent = Color(0xFFA95226) + + val challengeNotLiveBackground = Color(0xFFAEDFCD) + val joinChallengeBackground = Color(0xFFB6D4FB) + + val white = Color(0xFFFFFFFF) + val primary = Color(0xFF202122) + val progressive = Color(0xFF3366CC) +} + +object WidgetBackground { + val challengeNotOptInRadialGradient = R.drawable.widget_linear_gradient_background +} diff --git a/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetCombination.kt b/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetCombination.kt new file mode 100644 index 00000000000..2f044c9cc8e --- /dev/null +++ b/app/src/main/java/org/wikipedia/widgets/readingchallenge/WidgetCombination.kt @@ -0,0 +1,111 @@ +package org.wikipedia.widgets.readingchallenge + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import org.wikipedia.R +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit.DAYS + +data class WidgetCombination( + val iconResId: Int, + val backgroundColor: Color, + val contentColor: Color, + val progressColor: Color? = null, + val titleResId: Int? = null, + val subtitleResId: Int? = null +) + +object WidgetCombinations { + + // TODO: update iconResId when svg's are provided + val streakNeedsReading = listOf( + needsReadingCombination(textResId = R.string.reading_challenge_widget_reminder_dont_let_today_drift, iconResId = R.drawable.wp25_babyglobe_dreaming), + needsReadingCombination(textResId = R.string.reading_challenge_widget_reminder_before_day_snoozes, iconResId = R.drawable.wp25_babyglobe_dreaming), + needsReadingCombination(R.string.reading_challenge_widget_reminder_small_bit_counts, iconResId = R.drawable.wp25_babyglobe_dreaming), + needsReadingCombination(R.string.reading_challenge_widget_reminder_one_article_away, iconResId = R.drawable.wp25_babyglobe_dreaming), + needsReadingCombination(R.string.reading_challenge_widget_reminder_jump_in_anytime, iconResId = R.drawable.wp25_babyglobe_reading), + needsReadingCombination(R.string.reading_challenge_widget_reminder_quiet_reading_moment, iconResId = R.drawable.wp25_babyglobe_reading), + needsReadingCombination(R.string.reading_challenge_widget_reminder_keep_curiosity_going, iconResId = R.drawable.wp25_babyglobe_reading), + needsReadingCombination(R.string.reading_challenge_widget_reminder_one_article_away, iconResId = R.drawable.wp25_babyglobe_reading) + ) + + val enrolledNotStarted = listOf( + needsEnrolledNotStartedCombination( + titleResId = R.string.reading_challenge_widget_enrolled_not_started_title, + subtitleResId = R.string.reading_challenge_widget_enrolled_not_started_subtitle + ), + needsEnrolledNotStartedCombination( + titleResId = R.string.reading_challenge_widget_start_reading_challenge_title, + subtitleResId = R.string.reading_challenge_widget_start_reading_challenge_subtitle + ), + needsEnrolledNotStartedCombination( + titleResId = R.string.reading_challenge_widget_start_spin_up_new_streak_title, + subtitleResId = R.string.reading_challenge_widget_start_spin_up_new_streak_subtitle, + iconResId = R.drawable.wp25_babyglobe_synth_1 + ), + needsEnrolledNotStartedCombination( + titleResId = R.string.reading_challenge_widget_start_fresh_start_title, + subtitleResId = R.string.reading_challenge_widget_start_fresh_start_subtitle, + iconResId = R.drawable.wp25_babyglobe_synth_2 + ) + ) + + // Since we are showing progress bar the title from the doc is not required + val streakOngoing = listOf( + WidgetCombination( + iconResId = R.drawable.wp25_babyglobe_phone, + backgroundColor = WidgetColors.phoneReadingBackground, + contentColor = WidgetColors.phoneReadingContent, + progressColor = WidgetColors.phoneReadingProgressColor, + ), + WidgetCombination( + iconResId = R.drawable.wp25_babyglobe_dancing_1, + backgroundColor = WidgetColors.musicReadingBackground, + contentColor = WidgetColors.musicContent, + progressColor = WidgetColors.musicReadingProgressColor + ), + WidgetCombination( + iconResId = R.drawable.wp25_babyglobe_dancing_2, + backgroundColor = WidgetColors.musicReadingBackground, + contentColor = WidgetColors.musicContent, + progressColor = WidgetColors.musicReadingProgressColor + ), + WidgetCombination( + iconResId = R.drawable.wp25_babyglobe_outerspace, + backgroundColor = WidgetColors.spaceReadingBackground, + contentColor = WidgetColors.spaceContent, + progressColor = WidgetColors.spaceReadingProgressColor + ) + ) + + fun List.forToday( + enrollmentDate: LocalDate, + now: LocalDateTime = LocalDateTime.now() + ): WidgetCombination { + val daysSinceEnrollment = DAYS.between(enrollmentDate, now.toLocalDate()).coerceAtLeast(0) + return this[(daysSinceEnrollment % this.size).toInt()] + } + + private fun needsReadingCombination(@StringRes textResId: Int, @DrawableRes iconResId: Int) = + WidgetCombination( + iconResId = iconResId, + backgroundColor = WidgetColors.streakOngoingNeedsReadingBackground, + contentColor = WidgetColors.streakOngoingNeedsReadingContent, + titleResId = textResId + ) + + private fun needsEnrolledNotStartedCombination( + @StringRes titleResId: Int, + @StringRes subtitleResId: Int, + @DrawableRes iconResId: Int = R.drawable.wp25_babyglobe_reading, + ) = + WidgetCombination( + iconResId = iconResId, + backgroundColor = WidgetColors.challengeNotOptInBackground, + contentColor = WidgetColors.primary, + titleResId = titleResId, + subtitleResId = subtitleResId + ) +} diff --git a/app/src/main/res/drawable/dashboard_customize_24dp.xml b/app/src/main/res/drawable/dashboard_customize_24dp.xml new file mode 100644 index 00000000000..e68a23ed902 --- /dev/null +++ b/app/src/main/res/drawable/dashboard_customize_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_calendar_day_1.xml b/app/src/main/res/drawable/ic_calendar_day_1.xml new file mode 100644 index 00000000000..abc5bc600f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_day_1.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_calendar_day_25.xml b/app/src/main/res/drawable/ic_calendar_day_25.xml new file mode 100644 index 00000000000..046f03f181d --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_day_25.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_contract_24dp.xml b/app/src/main/res/drawable/ic_contract_24dp.xml new file mode 100644 index 00000000000..22b8b9dea87 --- /dev/null +++ b/app/src/main/res/drawable/ic_contract_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_featured_seasonal_and_gifts_24dp.xml b/app/src/main/res/drawable/ic_featured_seasonal_and_gifts_24dp.xml new file mode 100644 index 00000000000..3de6888e62d --- /dev/null +++ b/app/src/main/res/drawable/ic_featured_seasonal_and_gifts_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_flame_24dp.xml b/app/src/main/res/drawable/ic_flame_24dp.xml new file mode 100644 index 00000000000..76fbb2dbfc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_flame_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trophy24dp.xml b/app/src/main/res/drawable/ic_trophy24dp.xml new file mode 100644 index 00000000000..b20b15ab1cf --- /dev/null +++ b/app/src/main/res/drawable/ic_trophy24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_w_logo_shadow.png b/app/src/main/res/drawable/ic_w_logo_shadow.png new file mode 100644 index 00000000000..f9eca6acb33 Binary files /dev/null and b/app/src/main/res/drawable/ic_w_logo_shadow.png differ diff --git a/app/src/main/res/drawable/large_widget_preview_image.png b/app/src/main/res/drawable/large_widget_preview_image.png new file mode 100644 index 00000000000..0dcc35581a0 Binary files /dev/null and b/app/src/main/res/drawable/large_widget_preview_image.png differ diff --git a/app/src/main/res/drawable/progress_bar_end_bg.xml b/app/src/main/res/drawable/progress_bar_end_bg.xml new file mode 100644 index 00000000000..cfc2f47867b --- /dev/null +++ b/app/src/main/res/drawable/progress_bar_end_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_bar_start_bg.xml b/app/src/main/res/drawable/progress_bar_start_bg.xml new file mode 100644 index 00000000000..71c1f5db696 --- /dev/null +++ b/app/src/main/res/drawable/progress_bar_start_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/reading_challenge_blur_background.png b/app/src/main/res/drawable/reading_challenge_blur_background.png new file mode 100644 index 00000000000..a0f2c9c07cc Binary files /dev/null and b/app/src/main/res/drawable/reading_challenge_blur_background.png differ diff --git a/app/src/main/res/drawable/reading_challenge_reward.png b/app/src/main/res/drawable/reading_challenge_reward.png new file mode 100644 index 00000000000..f9540ddaf30 Binary files /dev/null and b/app/src/main/res/drawable/reading_challenge_reward.png differ diff --git a/app/src/main/res/drawable/reading_challenge_widget_example.png b/app/src/main/res/drawable/reading_challenge_widget_example.png new file mode 100644 index 00000000000..33ca0c8a54f Binary files /dev/null and b/app/src/main/res/drawable/reading_challenge_widget_example.png differ diff --git a/app/src/main/res/drawable/small_widget_preview_image.png b/app/src/main/res/drawable/small_widget_preview_image.png new file mode 100644 index 00000000000..a48a1d8e098 Binary files /dev/null and b/app/src/main/res/drawable/small_widget_preview_image.png differ diff --git a/app/src/main/res/drawable/widget_linear_gradient_background.xml b/app/src/main/res/drawable/widget_linear_gradient_background.xml new file mode 100644 index 00000000000..2ebb38cf182 --- /dev/null +++ b/app/src/main/res/drawable/widget_linear_gradient_background.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_shape_12dp_corner_radius.xml b/app/src/main/res/drawable/widget_shape_12dp_corner_radius.xml new file mode 100644 index 00000000000..d7aad10ef7f --- /dev/null +++ b/app/src/main/res/drawable/widget_shape_12dp_corner_radius.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_shape_background.xml b/app/src/main/res/drawable/widget_shape_background.xml index a17f5e4ffc7..4eca3cc7110 100644 --- a/app/src/main/res/drawable/widget_shape_background.xml +++ b/app/src/main/res/drawable/widget_shape_background.xml @@ -1,6 +1,6 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/wp25_babyglobe_celebration_neutral.png b/app/src/main/res/drawable/wp25_babyglobe_celebration_neutral.png new file mode 100644 index 00000000000..1a2946e5e0c Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_celebration_neutral.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_dancing_1.png b/app/src/main/res/drawable/wp25_babyglobe_dancing_1.png new file mode 100644 index 00000000000..878e9cea82a Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_dancing_1.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_dancing_2.png b/app/src/main/res/drawable/wp25_babyglobe_dancing_2.png new file mode 100644 index 00000000000..5fab85b39e0 Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_dancing_2.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_dreaming.png b/app/src/main/res/drawable/wp25_babyglobe_dreaming.png new file mode 100644 index 00000000000..b203a8c4653 Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_dreaming.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_outerspace.png b/app/src/main/res/drawable/wp25_babyglobe_outerspace.png new file mode 100644 index 00000000000..4fb637140fb Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_outerspace.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_phone.png b/app/src/main/res/drawable/wp25_babyglobe_phone.png new file mode 100644 index 00000000000..1ec466a0c95 Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_phone.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_reading.png b/app/src/main/res/drawable/wp25_babyglobe_reading.png new file mode 100644 index 00000000000..a9eb6c1c5de Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_reading.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_synth_1.png b/app/src/main/res/drawable/wp25_babyglobe_synth_1.png new file mode 100644 index 00000000000..ca3f5e8f893 Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_synth_1.png differ diff --git a/app/src/main/res/drawable/wp25_babyglobe_synth_2.png b/app/src/main/res/drawable/wp25_babyglobe_synth_2.png new file mode 100644 index 00000000000..c940f1315f5 Binary files /dev/null and b/app/src/main/res/drawable/wp25_babyglobe_synth_2.png differ diff --git a/app/src/main/res/layout/reading_challenge_widget_loading.xml b/app/src/main/res/layout/reading_challenge_widget_loading.xml new file mode 100644 index 00000000000..50419cd9015 --- /dev/null +++ b/app/src/main/res/layout/reading_challenge_widget_loading.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index c72bb5ecc6d..ebd2ed1bf43 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -208,4 +208,14 @@ hybridSearchOnboardingShown hybridSearchEnabled gameStatsSnackbarShown + readingChallengeStreak + readingChallengeEnrolled + readingChallengeLastReadDate + readingChallengeWidgets + readingChallengeOnboardingShown + readingChallengeInstallPromptShown + readingChallengeEnrollmentDate + readingChallengeStartDate + readingChallengeEndDate + readingChallengeWidgetFastCycle diff --git a/app/src/main/res/values/strings_no_translate.xml b/app/src/main/res/values/strings_no_translate.xml index 53959a6bdc3..f52b629e9fb 100644 --- a/app/src/main/res/values/strings_no_translate.xml +++ b/app/src/main/res/values/strings_no_translate.xml @@ -43,6 +43,8 @@ https://www.mediawiki.org/wiki/Special:MyLanguage/Wikimedia_Apps/About_the_Wikimedia_Foundation https://%s.wikipedia.org/wiki/Special:PasswordReset https://www.mediawiki.org/wiki/Readers/Information_Retrieval/Phase_1 + https://www.mediawiki.org/wiki/Special:MyLanguage/Wikimedia_Apps/Team/25th_Birthday_Reading_Challenge + https://store.wikimedia.org/discount/Widget15 @string/wikimedia org.wikimedia diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml index 8a99ba4fd94..7ba8db3494d 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -576,4 +576,10 @@ android:key="@string/preference_key_hybrid_search_enabled" android:title="@string/preference_key_hybrid_search_enabled" /> + + + + diff --git a/app/src/main/res/xml/reading_challenge_widget_info.xml b/app/src/main/res/xml/reading_challenge_widget_info.xml new file mode 100644 index 00000000000..0bb6118ede0 --- /dev/null +++ b/app/src/main/res/xml/reading_challenge_widget_info.xml @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/app/src/test/java/org/wikipedia/widget/ReadingChallengeWidgetRepositoryTest.kt b/app/src/test/java/org/wikipedia/widget/ReadingChallengeWidgetRepositoryTest.kt new file mode 100644 index 00000000000..3875c4957ad --- /dev/null +++ b/app/src/test/java/org/wikipedia/widget/ReadingChallengeWidgetRepositoryTest.kt @@ -0,0 +1,425 @@ +package org.wikipedia.widget + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import junit.framework.TestCase +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.wikipedia.settings.Prefs +import org.wikipedia.widgets.readingchallenge.ReadingChallengeState +import org.wikipedia.widgets.readingchallenge.ReadingChallengeUserData +import org.wikipedia.widgets.readingchallenge.ReadingChallengeWidgetRepository +import java.time.LocalDate + +class ReadingChallengeWidgetRepositoryTest { + private lateinit var context: Context + private lateinit var repository: ReadingChallengeWidgetRepository + + @Before + fun setup() { + context = mockk(relaxed = true) + mockkObject(Prefs) + repository = ReadingChallengeWidgetRepository(context) + every { Prefs.readingChallengeStartDate } returns START_DATE.toString() + every { Prefs.readingChallengeEndDate } returns END_DATE.toString() + } + + @After + fun teardown() { + unmockkAll() + } + + // Not live yet tests + @Test + fun `returns NotLiveYet before start date`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = DAY_BEFORE_START, + enabled = false, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.NotLiveYet) + } + + @Test + fun `returns NotEnrolled on start date since challenge is Live`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = START_DATE, + enabled = false, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertFalse(state is ReadingChallengeState.NotLiveYet) + TestCase.assertTrue(state is ReadingChallengeState.NotEnrolled) + } + + // Not enrolled tests + @Test + fun `returns NotEnrolled on last day of the challenge when not enrolled`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = END_DATE, + enabled = false, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.NotEnrolled) + } + + // Challenge Completed (streak >= 25, enrolled, on or before July 10) + @Test + fun `returns ChallengeCompleted when streak reaches goal in mid challenge`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE, + enabled = true, + currentStreak = 25, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeCompleted) + } + + @Test + fun `returns ChallengeCompleted when streak reaches goal near end of challenge`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = NEAR_END_CHALLENGE_DATE, + enabled = true, + currentStreak = 25, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeCompleted) + } + + @Test + fun `returns ChallengeCompleted after end date but before remove date with streak reached`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = AFTER_END_BEFORE_REMOVE_DATE, + enabled = true, + currentStreak = 25, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeCompleted) + } + + @Test + fun `does NOT return ChallengeCompleted when streak is one below the goal`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE, + enabled = true, + currentStreak = READING_STREAK_GOAL - 1, + hasReadToday = false + ) + ) + TestCase.assertFalse(state is ReadingChallengeState.ChallengeCompleted) + // Should be either StreakOngoingNeedsReading or StreakOngoingReadToday + TestCase.assertTrue( + state is ReadingChallengeState.StreakOngoingNeedsReading || + state is ReadingChallengeState.StreakOngoingReadToday + ) + } + + // Challenge Concluded tests + @Test + fun `returns ChallengeConcludedNoStreak on first day after end date with zero streak`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = DAY_AFTER_END, + enabled = true, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeConcludedNoStreak) + } + + @Test + fun `returns ChallengeConcludedIncomplete on first day after end date with partial streak`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = DAY_AFTER_END, + enabled = true, + currentStreak = 10, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeConcludedIncomplete) + } + + @Test + fun `does NOT return Concluded states on last day of challenge`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = END_DATE, + enabled = true, + currentStreak = 10, + hasReadToday = false + ) + ) + TestCase.assertFalse(state is ReadingChallengeState.ChallengeConcludedNoStreak) + TestCase.assertFalse(state is ReadingChallengeState.ChallengeConcludedIncomplete) + TestCase.assertFalse(state is ReadingChallengeState.ChallengeCompleted) + TestCase.assertTrue( + state is ReadingChallengeState.StreakOngoingNeedsReading || + state is ReadingChallengeState.StreakOngoingReadToday + ) + } + + // reset streak test + @Test + fun `streak resets when lastReadDate is more than 1 day ago during challenge`() { + val currentDate = MID_CHALLENGE_DATE + + every { Prefs.readingChallengeEnrolled } returns true + every { Prefs.readingChallengeStreak } returns 10 + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(2).toString() + + var newCurrentStreak = 10 + + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(0, newCurrentStreak) + } + + @Test + fun `streak resets on exactly last day of challenge when lastReadDate is more than 1 day ago`() { + val currentDate = END_DATE + + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(2).toString() + + var newCurrentStreak = 10 + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(0, newCurrentStreak) + } + + @Test + fun `streak should not reset after end date of when lastReadDate is more than 1 day ago`() { + val currentDate = DAY_AFTER_END + + every { Prefs.readingChallengeEnrolled } returns true + every { Prefs.readingChallengeStreak } returns 10 + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(2).toString() + var newCurrentStreak = 10 + + // if this is called, it means the streak was reset, which should not happen after May 31 + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(10, newCurrentStreak) + } + + @Test + fun `streak does not reset when lastReadDate is exactly yesterday`() { + val currentDate = MID_CHALLENGE_DATE + + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(1).toString() + var newCurrentStreak = 10 + + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(10, newCurrentStreak) + } + + @Test + fun `streak does not reset when lastReadDate is empty`() { + val currentDate = MID_CHALLENGE_DATE + + every { Prefs.readingChallengeLastReadDate } returns "" + var newCurrentStreak = 10 + + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(10, newCurrentStreak) + } + + // covers silently reset the streak counter to 0 + @Test + fun `return EnrolledNotStarted when streak resets`() { + val currentDate = MID_CHALLENGE_DATE + + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(2).toString() + var newCurrentStreak = 10 + + every { Prefs.readingChallengeStreak = any() } answers { newCurrentStreak = firstArg() } + + repository.recalculateStreakIfNeeded(currentDate) + + TestCase.assertEquals(0, newCurrentStreak) + + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = currentDate, + enabled = true, + currentStreak = newCurrentStreak, + hasReadToday = false + ) + ) + + TestCase.assertTrue(state is ReadingChallengeState.EnrolledNotStarted) + } + + // Enrolled not started test + @Test + fun `returns EnrolledNotStarted on start date with zero streak`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = START_DATE, + enabled = true, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.EnrolledNotStarted) + } + + @Test + fun `does NOT return EnrolledNotStarted after end date with zero streak`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = DAY_AFTER_END, + enabled = true, + currentStreak = 0, + hasReadToday = false + ) + ) + TestCase.assertFalse(state is ReadingChallengeState.EnrolledNotStarted) + TestCase.assertTrue(state is ReadingChallengeState.ChallengeConcludedNoStreak) + } + + // Streak Ongoing: Not Yet Read Today + @Test + fun `returns StreakOngoingNeedsReading when streak is active and not read today`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE, + enabled = true, + currentStreak = 10, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.StreakOngoingNeedsReading) + } + + @Test + fun `StreakOngoingNeedsReading carries correct streak count`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE.plusDays(5), + enabled = true, + currentStreak = 5, + hasReadToday = false + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.StreakOngoingNeedsReading) + TestCase.assertEquals(5, (state as ReadingChallengeState.StreakOngoingNeedsReading).streak) + } + + // Streak Ongoing: Already Read Today + @Test + fun `returns StreakOngoingReadToday when streak is active and read today`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE.plusDays(2), + enabled = true, + currentStreak = 10, + hasReadToday = true + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.StreakOngoingReadToday) + } + + @Test + fun `StreakOngoingReadToday carries correct streak count`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = MID_CHALLENGE_DATE.plusDays(6), + enabled = true, + currentStreak = 7, + hasReadToday = true + ) + ) + TestCase.assertTrue(state is ReadingChallengeState.StreakOngoingReadToday) + TestCase.assertEquals(7, (state as ReadingChallengeState.StreakOngoingReadToday).streak) + } + + // Remove challenge test + @Test + fun `returns ChallengeRemoved after remove date`() { + val state = repository.resolveState( + ReadingChallengeUserData( + currentDate = DAY_AFTER_REMOVE, + enabled = true, + currentStreak = 2, + hasReadToday = false + ) + ) + + TestCase.assertTrue(state is ReadingChallengeState.ChallengeRemoved) + } + + // has read today test + @Test + fun `hasReadToday returns true when lastReadDate matches currentDate`() { + val currentDate = MID_CHALLENGE_DATE + every { Prefs.readingChallengeLastReadDate } returns currentDate.toString() + + TestCase.assertTrue(repository.hasReadToday(currentDate)) + } + + @Test + fun `hasReadToday returns false when lastReadDate is yesterday`() { + val currentDate = MID_CHALLENGE_DATE + every { Prefs.readingChallengeLastReadDate } returns currentDate.minusDays(1).toString() + + TestCase.assertFalse(repository.hasReadToday(currentDate)) + } + + @Test + fun `hasReadToday returns false when lastReadDate is empty`() { + val currentDate = MID_CHALLENGE_DATE + every { Prefs.readingChallengeLastReadDate } returns "" + + TestCase.assertFalse(repository.hasReadToday(currentDate)) + } + + companion object { + // Update only these when dates change + private val START_DATE = LocalDate.of(2026, 5, 11) + private val END_DATE = LocalDate.of(2026, 6, 18) + private val REMOVE_DATE = LocalDate.of(2026, 7, 27) + + private const val READING_STREAK_GOAL = 25 + + // Derive dates based on the above dates + private val DAY_BEFORE_START = START_DATE.minusDays(1) + private val DAY_AFTER_END = END_DATE.plusDays(1) + private val DAY_AFTER_REMOVE = REMOVE_DATE.plusDays(1) + private val MID_CHALLENGE_DATE = START_DATE.plusDays(15) + private val NEAR_END_CHALLENGE_DATE = END_DATE.minusDays(3) + private val AFTER_END_BEFORE_REMOVE_DATE = REMOVE_DATE.minusDays(5) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d05f87fb057..411b40e20bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ espressoVersion = "3.7.0" firebaseMessagingVersion = "24.1.2" flexbox = "3.0.0" fragmentKtx = "1.8.9" +glance = "1.1.1" googlePayVersion = "20.0.0" googleServices = "4.4.4" gradle = "9.2.0" @@ -50,6 +51,7 @@ workRuntimeKtx = "2.11.2" composeBom = "2026.04.01" composeActivity = "1.13.0" composeViewModel = "2.10.0" +uiGraphics = "1.10.5" [libraries] @@ -122,6 +124,11 @@ compose-activity = { module = "androidx.activity:activity-compose", version.ref compose-view-model = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "composeViewModel" } compose-test = { module = "androidx.compose.ui:ui-test-junit4" } compose-debug-test = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" } +androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle" }