-
Notifications
You must be signed in to change notification settings - Fork 534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixes #3841 'time_ago' & quantity plurals should be combined #4533
Changes from all commits
4feb541
a486ff9
4d92f25
e3a46e1
c985faf
431412c
0c2cdd8
6908948
4048eee
022fb82
416dba4
6ae083e
af93514
44f718a
4096e57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
package org.oppia.android.app.databinding | ||
|
||
import android.app.Activity | ||
import android.content.ContextWrapper | ||
import android.view.View | ||
import android.widget.TextView | ||
import androidx.databinding.BindingAdapter | ||
import org.oppia.android.R | ||
import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider | ||
import org.oppia.android.app.translation.AppLanguageResourceHandler | ||
|
||
@BindingAdapter("profile:lastVisited") | ||
fun TextView.setProfileLastVisitedText(timestamp: Long?) { | ||
timestamp?.let { | ||
// text = | ||
} | ||
} | ||
|
||
@BindingAdapter("profile:created") | ||
fun setProfileDataText(textView: TextView, timestamp: Long) { | ||
val resourceHandler = getResourceHandler(textView) | ||
val time = resourceHandler.computeDateString(timestamp) | ||
textView.text = resourceHandler.getStringInLocaleWithWrapping( | ||
R.string.profile_edit_created, | ||
time | ||
) | ||
} | ||
|
||
// TODO: Move this to a common place. | ||
fun getResourceHandler(view: View): AppLanguageResourceHandler { | ||
val provider = | ||
getAttachedActivity(view) as AppLanguageActivityInjectorProvider | ||
return provider.getAppLanguageActivityInjector().getAppLanguageResourceHandler() | ||
} | ||
|
||
private fun getAttachedActivity(view: View): Activity { | ||
var context = view.context | ||
while (context != null && context !is Activity) { | ||
check(context is ContextWrapper) { | ||
( | ||
"Encountered context in view (" + view + ") that doesn't wrap a parent context: " + | ||
context | ||
) | ||
} | ||
context = context.baseContext | ||
} | ||
checkNotNull(context) { "Failed to find base Activity for view: $view" } | ||
return context as Activity | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,10 +16,14 @@ | |
import org.oppia.android.util.system.OppiaClock; | ||
import org.oppia.android.util.system.OppiaClockInjectorProvider; | ||
|
||
/** Holds all custom binding adapters that bind to [TextView]. */ | ||
/** | ||
* Holds all custom binding adapters that bind to [TextView]. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this change? Per https://developer.android.com/kotlin/style-guide#formatting_2 the previous version is acceptable (and is what we use for such cases). While I linked to the KDoc one, the same also applies for Java: https://google.github.io/styleguide/javaguide.html#s7.1-javadoc-formatting. Ditto for the others. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Corrected this |
||
*/ | ||
public final class TextViewBindingAdapters { | ||
|
||
/** Binds date text with relative time. */ | ||
/** | ||
* Binds date text with relative time. | ||
*/ | ||
@BindingAdapter("profile:created") | ||
public static void setProfileDataText(@NonNull TextView textView, long timestamp) { | ||
AppLanguageResourceHandler resourceHandler = getResourceHandler(textView); | ||
|
@@ -30,7 +34,9 @@ public static void setProfileDataText(@NonNull TextView textView, long timestamp | |
)); | ||
} | ||
|
||
/** Binds last used with relative timestamp. */ | ||
/** | ||
* Binds last used with relative timestamp. | ||
*/ | ||
@BindingAdapter("profile:lastVisited") | ||
public static void setProfileLastVisitedText(@NonNull TextView textView, long timestamp) { | ||
AppLanguageResourceHandler resourceHandler = getResourceHandler(textView); | ||
|
@@ -45,7 +51,10 @@ public static void setProfileLastVisitedText(@NonNull TextView textView, long ti | |
} | ||
|
||
// TODO(#4345): Add test for this method. | ||
/** Binds an AndroidX KitKat-compatible drawable top to the specified text view. */ | ||
|
||
/** | ||
* Binds an AndroidX KitKat-compatible drawable top to the specified text view. | ||
*/ | ||
@BindingAdapter("app:drawableTopCompat") | ||
public static void setDrawableTopCompat( | ||
@NonNull TextView imageView, | ||
|
@@ -56,7 +65,9 @@ public static void setDrawableTopCompat( | |
); | ||
} | ||
|
||
/** Binds an AndroidX KitKat-compatible drawable end to the specified text view. */ | ||
/** | ||
* Binds an AndroidX KitKat-compatible drawable end to the specified text view. | ||
*/ | ||
@BindingAdapter("app:drawableEndCompat") | ||
public static void setDrawableEndCompat( | ||
@NonNull TextView imageView, | ||
|
@@ -68,35 +79,34 @@ public static void setDrawableEndCompat( | |
} | ||
|
||
private static String getTimeAgo(View view, long lastVisitedTimestamp) { | ||
long timeStampMillis = ensureTimestampIsInMilliseconds(lastVisitedTimestamp); | ||
long currentTimeMillis = getOppiaClock(view).getCurrentTimeMs(); | ||
AppLanguageResourceHandler resourceHandler = getResourceHandler(view); | ||
|
||
if (timeStampMillis > currentTimeMillis || timeStampMillis <= 0) { | ||
if (lastVisitedTimestamp > currentTimeMillis || lastVisitedTimestamp <= 0) { | ||
return resourceHandler.getStringInLocale(R.string.last_logged_in_recently); | ||
} | ||
|
||
long timeDifferenceMillis = currentTimeMillis - timeStampMillis; | ||
long timeDifferenceMillis = currentTimeMillis - lastVisitedTimestamp; | ||
|
||
if (timeDifferenceMillis < (int) TimeUnit.MINUTES.toMillis(1)) { | ||
return resourceHandler.getStringInLocale(R.string.just_now); | ||
} else if (timeDifferenceMillis < TimeUnit.MINUTES.toMillis(50)) { | ||
return getPluralString( | ||
resourceHandler, | ||
resourceHandler, | ||
R.plurals.minutes, | ||
(int) TimeUnit.MILLISECONDS.toMinutes(timeDifferenceMillis) | ||
); | ||
} else if (timeDifferenceMillis < TimeUnit.DAYS.toMillis(1)) { | ||
return getPluralString( | ||
resourceHandler, | ||
resourceHandler, | ||
R.plurals.hours, | ||
(int) TimeUnit.MILLISECONDS.toHours(timeDifferenceMillis) | ||
); | ||
} else if (timeDifferenceMillis < TimeUnit.DAYS.toMillis(2)) { | ||
return resourceHandler.getStringInLocale(R.string.yesterday); | ||
} | ||
return getPluralString( | ||
resourceHandler, | ||
resourceHandler, | ||
R.plurals.days, | ||
(int) TimeUnit.MILLISECONDS.toDays(timeDifferenceMillis) | ||
); | ||
|
@@ -107,24 +117,11 @@ private static String getPluralString( | |
@PluralsRes int pluralsResId, | ||
int count | ||
) { | ||
// TODO(#3841): Combine these strings together. | ||
return resourceHandler.getStringInLocaleWithWrapping( | ||
R.string.time_ago, | ||
resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
return resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
pluralsResId, count, String.valueOf(count) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this indentation still correct? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes this is correct |
||
) | ||
); | ||
} | ||
|
||
private static long ensureTimestampIsInMilliseconds(long lastVisitedTimestamp) { | ||
// TODO(#3842): Investigate & remove this check. | ||
if (lastVisitedTimestamp < 1000000000000L) { | ||
// If timestamp is given in seconds, convert that to milliseconds. | ||
return TimeUnit.SECONDS.toMillis(lastVisitedTimestamp); | ||
} | ||
return lastVisitedTimestamp; | ||
} | ||
|
||
private static AppLanguageResourceHandler getResourceHandler(View view) { | ||
AppLanguageActivityInjectorProvider provider = | ||
(AppLanguageActivityInjectorProvider) getAttachedActivity(view); | ||
|
@@ -136,8 +133,8 @@ private static Activity getAttachedActivity(View view) { | |
while (context != null && !(context instanceof Activity)) { | ||
if (!(context instanceof ContextWrapper)) { | ||
throw new IllegalStateException( | ||
"Encountered context in view (" + view + ") that doesn't wrap a parent context: " | ||
+ context | ||
"Encountered context in view (" + view + ") that doesn't wrap a parent context: " | ||
+ context | ||
); | ||
} | ||
context = ((ContextWrapper) context).getBaseContext(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -101,15 +101,16 @@ class ProfileEditFragmentPresenter @Inject constructor( | |
ProfileId.newBuilder().setInternalId(internalProfileId).build(), | ||
binding.profileEditAllowDownloadSwitch.isChecked | ||
).toLiveData().observe( | ||
activity, | ||
Observer { | ||
if (it is AsyncResult.Failure) { | ||
oppiaLogger.e( | ||
"ProfileEditActivityPresenter", "Failed to updated allow download access", it.error | ||
) | ||
} | ||
activity | ||
) { | ||
if (it is AsyncResult.Failure) { | ||
oppiaLogger.e( | ||
"ProfileEditActivityPresenter", | ||
"Failed to updated allow download access", | ||
it.error | ||
) | ||
} | ||
) | ||
} | ||
} | ||
return binding.root | ||
} | ||
|
@@ -132,22 +133,21 @@ class ProfileEditFragmentPresenter @Inject constructor( | |
profileManagementController | ||
.deleteProfile(ProfileId.newBuilder().setInternalId(internalProfileId).build()).toLiveData() | ||
.observe( | ||
fragment, | ||
Observer { | ||
if (it is AsyncResult.Success) { | ||
if (fragment.requireContext().resources.getBoolean(R.bool.isTablet)) { | ||
val intent = | ||
Intent(fragment.requireContext(), AdministratorControlsActivity::class.java) | ||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
fragment.startActivity(intent) | ||
} else { | ||
val intent = Intent(fragment.requireContext(), ProfileListActivity::class.java) | ||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
fragment.startActivity(intent) | ||
} | ||
fragment | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not opposed to including this & other cleanup changes in this PR, but something to keep in mind in the future is that it's not always best practice to include unrelated changes in a PR to the central thing the PR is trying to accomplish. Doing so actually introduces some risk in that it can cause the PR to need to be reverted if the refactor has an unexpected behavior change, or it can be difficult to revert the PR due to a larger number of changes than what's actually necessary to accomplish its goal. |
||
) { | ||
if (it is AsyncResult.Success) { | ||
if (fragment.requireContext().resources.getBoolean(R.bool.isTablet)) { | ||
val intent = | ||
Intent(fragment.requireContext(), AdministratorControlsActivity::class.java) | ||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
fragment.startActivity(intent) | ||
} else { | ||
val intent = Intent(fragment.requireContext(), ProfileListActivity::class.java) | ||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
fragment.startActivity(intent) | ||
} | ||
} | ||
) | ||
} | ||
} | ||
|
||
/** This loads the dialog whenever requested by the listener in [AdministratorControlsActivity]. */ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ package org.oppia.android.app.utility.datetime | |
import org.oppia.android.R | ||
import org.oppia.android.app.translation.AppLanguageResourceHandler | ||
import org.oppia.android.util.locale.OppiaLocale | ||
import java.util.Calendar | ||
import javax.inject.Inject | ||
|
||
/** Per-activity utility to manage date and time for user-facing strings. */ | ||
|
@@ -30,4 +31,49 @@ class DateTimeUtil @Inject constructor( | |
/** Returns [DateTimeUtil] for the current Dagger graph. */ | ||
fun getDateTimeUtil(): DateTimeUtil | ||
} | ||
|
||
/** | ||
* Returns the readable string of the duration from the provided time in [Long]. | ||
*/ | ||
fun timeAgoFromTimestamp(timestamp: Long, referenceTime: Long): String { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Method names should be verbs or verb phrases describing what's happening (e.g. formatting, or converting, in this case). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add new tests for this method to cover all possible pluralizations that should now be supported. |
||
val diff = (currentDate() - timestamp) / MILLI_SECONDS | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this & the constants below, could we use val diffSeconds = TimeUnit.MILLISECONDS.toSeconds(currentTimeMillis - timestampMillis)
return when {
diffSeconds < TimeUnit.MINUTES.toSeconds(1) -> ...
diffSeconds < TimeUnit.HOURS.toSeconds(1) -> ...
...
} I think private val timestampFormatters by lazy {
listOf(
TimestampFormatter(threshold = TimeMoment.ONE_MINUTE, timeUnit = TimeUnit.SECONDS, formatTimestamp = this::formatSecondsTimestamp),
TimestampFormatter(threshold = TimeMoment.ONE_HOUR, timeUnit = TimeUnit.MINUTES, formatTimestamp = this::formatMinutesTimestamp),
...
)
}
fun formatTimeAgoTimestamp(timestampMillis: Long): String {
val timestamp = TimeMoment(amount = timestampMillis, unit = TimeUnit.MILLISECONDS)
val currentTime = TimeMoment(amount = oppiaClock.getCurrentTimeMillis(), unit = TimeUnit.MILLISECONDS)
val timeSpent = currentTime - timestamp
return timestampFormatters.firstOrNull {
it.canFormatWithinThreshold(timeSpent)
}?.formatString(timeSpent) ?: error("Encountered impossible time: $timeSpent (for time: $timestamp)")
}
private class TimestampFormatter(val threshold: TimeSpan, val timeUnit: TimeUnit, val formatTimestamp: (TimeMoment) -> String) {
fun canFormatWithinThreshold(timestamp: TimeMoment): Boolean = timestamp.isEarlierThan(threshold)
fun format(timestamp: TimeMoment): String = formatTimestamp(timestamp.convertTo(timeUnit))
}
private fun formatSecondsTimestamp(seconds: TimeMoment): String =
resourceHandler.getStringInLocale(R.string.just_now)
private fun formatMinutesTimestamp(minutes: TimeMoment): String {
return resourceHandler.getQuantityStringInLocaleWithWrapping(
R.plurals.minutes_ago, minutes.asInt, minutes.asString
)
}
...
// A simple alternative for Java 8-only time utilities.
private data class TimeMoment(val amount: Long, val unit: TimeUnit) {
val inMilliseconds: Long
get() = TimeUnit.MILLISECONDS.convert(amount, unit)
val asInt: Int
get() = amount.toInt()
val asString: String
get() = amount.toString()
fun isEarlierThan(other: TimeMoment): Boolean = inMilliseconds < other.inMilliseconds
fun convertTo(unit: TimeUnit): TimeMoment = TimeMoment(unit.convert(amount, this.unit), unit)
operator fun minus(other: TimeMoment): TimeMoment =
TimeMoment(inMilliseconds - other.inMilliseconds, TimeUnit.MILLISECONDS)
companion object {
val ONE_MINUTE = TimeMoment(amount = 1, unit = TimeUnit.MINUTES)
val ONE_HOUR = TimeMoment(amount = 1, unit = TimeUnit.HOURS)
...
}
} (As this is just a concept, more work & reformatting is needed). |
||
|
||
return when { | ||
diff < SECONDS -> resourceHandler.getStringInLocale(R.string.just_now) | ||
diff < HOUR -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.minutes_ago, diff.diffToInt(SECONDS), diff.diffToString(SECONDS) | ||
) | ||
diff < DAY -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.hours_ago, diff.diffToInt(HOUR), diff.diffToString(HOUR) | ||
) | ||
diff < WEEK -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.days_ago, diff.diffToInt(DAY), diff.diffToString(DAY) | ||
) | ||
diff < WEEKS -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.weeks_ago, diff.diffToInt(WEEK), diff.diffToString(WEEK) | ||
) | ||
diff < MONTHS -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.months_ago, | ||
diff.diffToInt(WEEKS), | ||
diff.diffToString(WEEKS) | ||
) | ||
else -> resourceHandler.getQuantityStringInLocaleWithWrapping( | ||
R.plurals.years_ago, diff.diffToInt(MONTHS), diff.diffToString(MONTHS) | ||
) | ||
} | ||
} | ||
|
||
companion object { | ||
fun currentDate() = Calendar.getInstance().timeInMillis | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this being used vs. passing in the current time? I think we conventionally use OppiaClock for calculating the current time, so I'm trying to understand this difference. |
||
const val MILLI_SECONDS = 1000L | ||
const val SECONDS = 60L | ||
const val HOUR = 60 * SECONDS | ||
const val DAY = 24 * HOUR | ||
const val WEEK = 7 * DAY | ||
const val WEEKS = 2_628_000L | ||
const val MONTHS = 31_536_000L | ||
} | ||
} | ||
|
||
private fun Long.diffToInt(duration: Long) = div(duration).toInt() | ||
private fun Long.diffToString(duration: Long) = div(duration).toString() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<resources> | ||
|
||
<plurals name="minutes_ago"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We usually define plurals in strings.xml--is there a reason these can't go there? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this & the others: the plural name should more clearly indicate what this is being used for as that provides better context to translators and within code. For example: |
||
<item quantity="one">%s minute ago</item> | ||
<item quantity="other">%s minutes ago</item> | ||
</plurals> | ||
|
||
<plurals name="hours_ago"> | ||
<item quantity="one">%s hour ago</item> | ||
<item quantity="other">%s hours ago</item> | ||
</plurals> | ||
|
||
<plurals name="days_ago"> | ||
<item quantity="one">%s day ago</item> | ||
<item quantity="other">%s days ago</item> | ||
</plurals> | ||
|
||
<plurals name="weeks_ago"> | ||
<item quantity="one">%s week ago</item> | ||
<item quantity="other">%s weeks ago</item> | ||
</plurals> | ||
|
||
<plurals name="months_ago"> | ||
<item quantity="one">%s month ago</item> | ||
<item quantity="other">%s months ago</item> | ||
</plurals> | ||
|
||
<plurals name="years_ago"> | ||
<item quantity="one">%s year ago</item> | ||
<item quantity="other">%s years ago</item> | ||
</plurals> | ||
|
||
</resources> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: all files should end with a single EOF newline. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -342,6 +342,7 @@ | |
<string name="profile_edit_delete_dialog_title">Permanently delete this profile?</string> | ||
<string name="profile_edit_delete_dialog_message">All progress will be deleted and cannot be recovered.</string> | ||
<string name="profile_edit_delete_dialog_positive">Delete</string> | ||
<string name="profile_edit_delete_successful_message">Profile deleted successfully</string> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems unrelated to your change--why is it needed? |
||
<string name="profile_edit_delete_dialog_negative">Cancel</string> | ||
<string name="profile_edit_allow_download_heading">Allow Download Access</string> | ||
<string name="profile_edit_allow_download_sub">User is able to download and delete content without Administrator password</string> | ||
|
@@ -467,6 +468,16 @@ | |
<item quantity="one">a day</item> | ||
<item quantity="other">%s days</item> | ||
</plurals> | ||
|
||
<string name="minute_ago">a minute ago</string> | ||
<string name="minutes_ago">%s minutes ago</string> | ||
<string name="hour_ago">an hour ago</string> | ||
<string name="hours_ago">%s hours ago</string> | ||
<string name="days_ago">%s days ago</string> | ||
<string name="month_ago">a month ago</string> | ||
<string name="months_ago">%s months ago</string> | ||
<string name="year_ago">a year ago</string> | ||
<string name="years_ago">%s years ago</string> | ||
Comment on lines
+472
to
+480
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these still needed? It seems like your change is only using the new plurals. Also, are there any old strings that can now be removed in favor of the new solution? |
||
<!-- ViewTags --> | ||
<string name="topic_revision_recyclerview_tag">topic_revision_recyclerview_tag</string> | ||
<string name="ongoing_recycler_view_tag">ongoing_recycler_view_tag</string> | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are these new binding adapters needed for this PR?