Skip to content

Commit 3ebd9d5

Browse files
committed
feat: backup relative dates
1 parent d1f8e8e commit 3ebd9d5

File tree

6 files changed

+233
-7
lines changed

6 files changed

+233
-7
lines changed

app/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ val keystoreProperties by lazy {
3434
keystoreProperties
3535
}
3636

37+
val locales = listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru")
38+
3739
android {
3840
namespace = "to.bitkit"
3941
compileSdk = 35
@@ -49,6 +51,7 @@ android {
4951
}
5052
buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false")
5153
buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true")
54+
buildConfigField("String", "LOCALES", "\"${locales.joinToString(",")}\"")
5255
}
5356

5457
flavorDimensions += "network"
@@ -131,7 +134,7 @@ android {
131134
}
132135
androidResources {
133136
@Suppress("UnstableApiUsage")
134-
localeFilters.addAll(listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru"))
137+
localeFilters.addAll(locales)
135138
@Suppress("UnstableApiUsage")
136139
generateLocaleConfig = true
137140
}

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal object Env {
1818
const val isE2eTest = BuildConfig.E2E
1919
const val isGeoblockingEnabled = BuildConfig.GEO
2020
val network = Network.valueOf(BuildConfig.NETWORK)
21+
val locales = BuildConfig.LOCALES.split(",")
2122
val walletSyncIntervalSecs = 10_uL // TODO review
2223
val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
2324
const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"

app/src/main/java/to/bitkit/ext/DateTime.kt

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
@file:Suppress("TooManyFunctions")
1+
@file:Suppress("TooManyFunctions", "MagicNumber")
22

33
package to.bitkit.ext
44

55
import android.icu.text.DateFormat
6+
import android.icu.text.DisplayContext
7+
import android.icu.text.RelativeDateTimeFormatter
68
import android.icu.util.ULocale
79
import kotlinx.datetime.Clock
810
import kotlinx.datetime.LocalDate
@@ -48,9 +50,82 @@ fun Long.toDateUTC(): String {
4850
fun Long.toLocalizedTimestamp(): String {
4951
val uLocale = ULocale.forLocale(Locale.US)
5052
val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale)
53+
?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this))
5154
return formatter.format(Date(this))
5255
}
5356

57+
@Suppress("LongMethod")
58+
fun Long.toRelativeTimeString(
59+
locale: Locale = Locale.getDefault(),
60+
clock: Clock = Clock.System,
61+
): String {
62+
val now = nowMillis(clock)
63+
val diffMillis = now - this
64+
65+
val formatter = RelativeDateTimeFormatter.getInstance(
66+
ULocale.forLocale(locale),
67+
null,
68+
RelativeDateTimeFormatter.Style.LONG,
69+
DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE,
70+
) ?: return toLocalizedTimestamp()
71+
72+
val seconds = diffMillis / 1000.0
73+
val minutes = (seconds / 60.0).toInt()
74+
val hours = (minutes / 60.0).toInt()
75+
val days = (hours / 24.0).toInt()
76+
val weeks = (days / 7.0).toInt()
77+
val months = (days / 30.0).toInt()
78+
val years = (days / 365.0).toInt()
79+
80+
return when {
81+
seconds < 60 -> formatter.format(
82+
RelativeDateTimeFormatter.Direction.PLAIN,
83+
RelativeDateTimeFormatter.AbsoluteUnit.NOW,
84+
)
85+
86+
minutes < 60 -> formatter.format(
87+
minutes.toDouble(),
88+
RelativeDateTimeFormatter.Direction.LAST,
89+
RelativeDateTimeFormatter.RelativeUnit.MINUTES,
90+
)
91+
92+
hours < 24 -> formatter.format(
93+
hours.toDouble(),
94+
RelativeDateTimeFormatter.Direction.LAST,
95+
RelativeDateTimeFormatter.RelativeUnit.HOURS,
96+
)
97+
98+
days < 2 -> formatter.format(
99+
RelativeDateTimeFormatter.Direction.LAST,
100+
RelativeDateTimeFormatter.AbsoluteUnit.DAY,
101+
)
102+
103+
days < 7 -> formatter.format(
104+
days.toDouble(),
105+
RelativeDateTimeFormatter.Direction.LAST,
106+
RelativeDateTimeFormatter.RelativeUnit.DAYS,
107+
)
108+
109+
weeks < 4 -> formatter.format(
110+
weeks.toDouble(),
111+
RelativeDateTimeFormatter.Direction.LAST,
112+
RelativeDateTimeFormatter.RelativeUnit.WEEKS,
113+
)
114+
115+
months < 12 -> formatter.format(
116+
months.toDouble(),
117+
RelativeDateTimeFormatter.Direction.LAST,
118+
RelativeDateTimeFormatter.RelativeUnit.MONTHS,
119+
)
120+
121+
else -> formatter.format(
122+
years.toDouble(),
123+
RelativeDateTimeFormatter.Direction.LAST,
124+
RelativeDateTimeFormatter.RelativeUnit.YEARS,
125+
)
126+
}
127+
}
128+
54129
fun getDaysInMonth(month: LocalDate): List<LocalDate?> {
55130
val firstDayOfMonth = LocalDate(month.year, month.month, CalendarConstants.FIRST_DAY_OF_MONTH)
56131
val daysInMonth = month.month.length(isLeapYear(month.year))

app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
2828
import androidx.navigation.NavController
2929
import to.bitkit.R
3030
import to.bitkit.env.Env
31-
import to.bitkit.ext.toLocalizedTimestamp
31+
import to.bitkit.ext.toRelativeTimeString
3232
import to.bitkit.models.BackupCategory
3333
import to.bitkit.models.BackupItemStatus
3434
import to.bitkit.ui.Routes
@@ -156,13 +156,18 @@ private fun BackupStatusItem(
156156
) {
157157
val status = uiState.status
158158

159+
val timeString = if (status.synced == 0L) {
160+
stringResource(R.string.common__never)
161+
} else {
162+
status.synced.toRelativeTimeString()
163+
}
164+
159165
val subtitle = when {
160-
status.running -> "Running" // TODO add missing localized text
166+
status.running -> stringResource(R.string.settings__backup__status_running)
161167
!status.isRequired -> stringResource(R.string.settings__backup__status_success)
162-
.replace("{time}", status.synced.toLocalizedTimestamp())
163-
168+
.replace("{time}", timeString)
164169
else -> stringResource(R.string.settings__backup__status_failed)
165-
.replace("{time}", status.synced.toLocalizedTimestamp())
170+
.replace("{time}", timeString)
166171
}
167172

168173
Row(

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
<string name="common__no">No</string>
6666
<string name="common__back">Back</string>
6767
<string name="common__learn_more">Learn More</string>
68+
<string name="common__never">Never</string>
6869
<string name="common__understood">Understood</string>
6970
<string name="common__connect">Connect</string>
7071
<string name="common__min">Min</string>
@@ -625,6 +626,7 @@
625626
<string name="settings__backup__failed_title">Data Backup Failure</string>
626627
<string name="settings__backup__failed_message">Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}.</string>
627628
<string name="settings__backup__latest">latest data backups</string>
629+
<string name="settings__backup__status_running">Running</string>
628630
<string name="settings__backup__status_failed">Failed Backup: {time}</string>
629631
<string name="settings__backup__status_success">Latest Backup: {time}</string>
630632
<string name="settings__backup__category_connections">Connections</string>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package to.bitkit.ext
2+
3+
import org.junit.Test
4+
import to.bitkit.env.Env
5+
import to.bitkit.test.BaseUnitTest
6+
import java.util.Locale
7+
import java.util.concurrent.TimeUnit
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertNotNull
10+
import kotlin.test.assertTrue
11+
12+
class DateTimeExtTest : BaseUnitTest() {
13+
14+
@Test
15+
fun `toRelativeTimeString returns now for very recent timestamps`() {
16+
val now = System.currentTimeMillis()
17+
val result = now.toRelativeTimeString()
18+
// May return "now" or absolute timestamp as fallback
19+
assertTrue(result.isNotEmpty())
20+
}
21+
22+
@Test
23+
fun `toRelativeTimeString returns minutes ago for recent timestamps`() {
24+
val fiveMinutesAgo = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)
25+
val result = fiveMinutesAgo.toRelativeTimeString()
26+
// May return relative "minute" or absolute timestamp as fallback
27+
assertTrue(result.isNotEmpty())
28+
}
29+
30+
@Test
31+
fun `toRelativeTimeString returns hours ago for timestamps within a day`() {
32+
val twoHoursAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
33+
val result = twoHoursAgo.toRelativeTimeString()
34+
// May return relative "hour" or absolute timestamp as fallback
35+
assertTrue(result.isNotEmpty())
36+
}
37+
38+
@Test
39+
fun `toRelativeTimeString returns yesterday for one day ago`() {
40+
val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)
41+
val result = oneDayAgo.toRelativeTimeString()
42+
// May return relative "yesterday"/"day" or absolute timestamp as fallback
43+
assertTrue(result.isNotEmpty())
44+
}
45+
46+
@Test
47+
fun `toRelativeTimeString returns days ago for multiple days`() {
48+
val threeDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3)
49+
val result = threeDaysAgo.toRelativeTimeString()
50+
// May return relative "day" or absolute timestamp as fallback
51+
assertTrue(result.isNotEmpty())
52+
}
53+
54+
@Test
55+
fun `toRelativeTimeString returns weeks ago for multiple weeks`() {
56+
val twoWeeksAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(14)
57+
val result = twoWeeksAgo.toRelativeTimeString()
58+
// May return relative "week" or absolute timestamp as fallback
59+
assertTrue(result.isNotEmpty())
60+
}
61+
62+
@Test
63+
fun `toRelativeTimeString returns months ago for multiple months`() {
64+
val twoMonthsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60)
65+
val result = twoMonthsAgo.toRelativeTimeString()
66+
// May return relative "month" or absolute timestamp as fallback
67+
assertTrue(result.isNotEmpty())
68+
}
69+
70+
@Test
71+
fun `toRelativeTimeString returns years ago for multiple years`() {
72+
val twoYearsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(730)
73+
val result = twoYearsAgo.toRelativeTimeString()
74+
// May return relative "year" or absolute timestamp as fallback
75+
assertTrue(result.isNotEmpty())
76+
}
77+
78+
@Test
79+
fun `toRelativeTimeString handles future timestamps gracefully`() {
80+
val future = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)
81+
val result = future.toRelativeTimeString()
82+
// May return relative "in" or absolute timestamp as fallback
83+
assertTrue(result.isNotEmpty())
84+
}
85+
86+
@Test
87+
fun `toRelativeTimeString supports all configured locales`() {
88+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
89+
90+
Env.locales.forEach { languageTag ->
91+
val locale = Locale.forLanguageTag(languageTag)
92+
val result = twoDaysAgo.toRelativeTimeString(locale)
93+
94+
assertNotNull(result, "Locale $languageTag returned null")
95+
assertTrue(result.isNotEmpty(), "Locale $languageTag returned empty string")
96+
}
97+
}
98+
99+
@Test
100+
fun `toRelativeTimeString with explicit English locale produces expected output`() {
101+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
102+
val result = twoDaysAgo.toRelativeTimeString(Locale.ENGLISH)
103+
104+
// May return relative "day" or absolute timestamp as fallback
105+
assertTrue(result.isNotEmpty())
106+
}
107+
108+
@Test
109+
fun `toRelativeTimeString with explicit German locale produces non-empty output`() {
110+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
111+
val result = twoDaysAgo.toRelativeTimeString(Locale.GERMAN)
112+
113+
assertTrue(result.isNotEmpty())
114+
}
115+
116+
@Test
117+
fun `toRelativeTimeString with explicit French locale produces non-empty output`() {
118+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
119+
val result = twoDaysAgo.toRelativeTimeString(Locale.FRENCH)
120+
121+
assertTrue(result.isNotEmpty())
122+
}
123+
124+
@Test
125+
fun `toRelativeTimeString with explicit Italian locale produces non-empty output`() {
126+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
127+
val result = twoDaysAgo.toRelativeTimeString(Locale.ITALIAN)
128+
129+
assertTrue(result.isNotEmpty())
130+
}
131+
132+
@Test
133+
fun `toRelativeTimeString preserves backward compatibility with default locale`() {
134+
val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
135+
val resultWithoutParam = twoDaysAgo.toRelativeTimeString()
136+
val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString(Locale.getDefault())
137+
138+
assertEquals(resultWithDefaultParam, resultWithoutParam)
139+
}
140+
}

0 commit comments

Comments
 (0)