From 91e9b4a259e7c76d74f773826a588d3f847b0f88 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Mon, 27 Jul 2026 19:37:51 +0300 Subject: [PATCH 1/7] fix: respect system 24-hour time setting --- app/src/main/java/to/bitkit/ext/Context.kt | 6 ++ app/src/main/java/to/bitkit/ext/DateTime.kt | 49 +++++++++-- app/src/main/java/to/bitkit/ext/Numbers.kt | 11 --- .../wallets/activity/ActivityDetailScreen.kt | 8 +- .../activity/components/ActivityRow.kt | 25 +++--- .../java/to/bitkit/ext/DateTimeExtTest.kt | 85 +++++++++++++++++++ changelog.d/next/677.fixed.md | 1 + 7 files changed, 149 insertions(+), 36 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ext/Numbers.kt create mode 100644 changelog.d/next/677.fixed.md diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index eac7fa1ac4..5a0ea09d82 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -16,6 +16,7 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.hardware.usb.UsbManager import android.os.PowerManager import android.provider.Settings +import android.text.format.DateFormat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri @@ -55,6 +56,11 @@ val Context.powerManager: PowerManager val Context.usageStatsManager: UsageStatsManager get() = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager +// Settings + +val Context.is24HourTimeFormat: Boolean + get() = DateFormat.is24HourFormat(this) + // Permissions fun Context.requiresPermission(permission: String): Boolean = diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 516944bcb0..4dfe4ecb40 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,5 +1,6 @@ package to.bitkit.ext +import android.content.Context import android.icu.text.DateFormat import android.icu.text.DisplayContext import android.icu.text.NumberFormat @@ -39,9 +40,13 @@ fun Clock.nowMs(): Long = now().toEpochMilliseconds() fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) -fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String { - val dateTime = LocalDateTime.ofInstant(this, ZoneId.systemDefault()) - val formatter = DateTimeFormatter.ofPattern(pattern) +fun Instant.formatted( + pattern: String = DatePattern.DATE_TIME, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): String { + val dateTime = LocalDateTime.ofInstant(this, zone) + val formatter = DateTimeFormatter.ofPattern(pattern, locale) return dateTime.format(formatter) } @@ -213,13 +218,41 @@ fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).a timeZone = java.util.TimeZone.getTimeZone("UTC") } +fun Context.uiDateText( + timestamp: ULong, + style: UiDateStyle, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): String = Instant.ofEpochSecond(timestamp.toLong()) + .formatted(style.pattern(is24HourTimeFormat), locale, zone) + +enum class UiDateStyle { + TIME, + DATE, + DATE_TIME, + DATE_TIME_YEAR, + ; + + fun pattern(is24Hour: Boolean): String { + val time = if (is24Hour) TIME_24H else TIME_12H + return when (this) { + TIME -> time + DATE -> DAY + DATE_TIME -> "$DAY, $time" + DATE_TIME_YEAR -> "$DAY_WITH_YEAR, $time" + } + } + + private companion object { + const val TIME_12H = "h:mm a" + const val TIME_24H = "HH:mm" + const val DAY = "MMMM d" + const val DAY_WITH_YEAR = "MMMM d yyyy" + } +} + object DatePattern { const val DATE_TIME = "dd/MM/yyyy, HH:mm" - const val INVOICE_EXPIRY = "MMM dd, h:mm a" - const val ACTIVITY_DATE = "MMMM d" - const val ACTIVITY_ROW_DATE = "MMMM d, HH:mm" - const val ACTIVITY_ROW_DATE_YEAR = "MMMM d yyyy, HH:mm" - const val ACTIVITY_TIME = "h:mm" const val CHANNEL_DETAILS = "MMM d, yyyy, HH:mm" const val LOG_FILE = "yyyy-MM-dd_HH-mm-ss" const val LOG_LINE = "yyyy-MM-dd HH:mm:ss.SSS" diff --git a/app/src/main/java/to/bitkit/ext/Numbers.kt b/app/src/main/java/to/bitkit/ext/Numbers.kt deleted file mode 100644 index 92de7037f8..0000000000 --- a/app/src/main/java/to/bitkit/ext/Numbers.kt +++ /dev/null @@ -1,11 +0,0 @@ -package to.bitkit.ext - -import java.time.Instant - -fun ULong.toActivityItemDate(): String { - return Instant.ofEpochSecond(this.toLong()).formatted(DatePattern.ACTIVITY_DATE) -} - -fun ULong.toActivityItemTime(): String { - return Instant.ofEpochSecond(this.toLong()).formatted(DatePattern.ACTIVITY_TIME) -} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 2a295347c0..535f07cbd0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -55,6 +55,7 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import to.bitkit.R +import to.bitkit.ext.UiDateStyle import to.bitkit.ext.contact import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle @@ -62,9 +63,8 @@ import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.timestamp -import to.bitkit.ext.toActivityItemDate -import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue +import to.bitkit.ext.uiDateText import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -433,7 +433,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - BodySSB(text = timestamp.toActivityItemDate()) + BodySSB(text = LocalContext.current.uiDateText(timestamp, UiDateStyle.DATE)) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() @@ -454,7 +454,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - BodySSB(text = timestamp.toActivityItemTime()) + BodySSB(text = LocalContext.current.uiDateText(timestamp, UiDateStyle.TIME)) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index a26a324811..9bf39c6804 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.activity.components +import android.content.Context import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -28,14 +29,14 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.DatePattern -import to.bitkit.ext.formatted +import to.bitkit.ext.UiDateStyle import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType +import to.bitkit.ext.uiDateText import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -138,7 +139,7 @@ fun ActivityRow( ) val context = LocalContext.current val subtitleText = when (item) { - is Activity.Lightning -> item.v1.message.ifEmpty { formattedTime(timestamp) } + is Activity.Lightning -> item.v1.message.ifEmpty { context.activityTimeText(timestamp) } is Activity.Onchain -> { when { !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) @@ -161,7 +162,7 @@ fun ActivityRow( .replace("{duration}", duration) } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> context.activityTimeText(timestamp) else -> { val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) @@ -363,18 +364,16 @@ private fun AmountViewContent( } } -private fun formattedTime(timestamp: ULong): String { - val instant = Instant.ofEpochSecond(timestamp.toLong()) - val dateTime = instant.atZone(ZoneId.systemDefault()) +private fun Context.activityTimeText(timestamp: ULong): String { + val dateTime = Instant.ofEpochSecond(timestamp.toLong()).atZone(ZoneId.systemDefault()) val now = LocalDate.now() - val isToday = dateTime.toLocalDate() == now - val isThisYear = dateTime.year == now.year - return when { - isToday -> instant.formatted(DatePattern.ACTIVITY_TIME) - isThisYear -> instant.formatted(DatePattern.ACTIVITY_ROW_DATE) - else -> instant.formatted(DatePattern.ACTIVITY_ROW_DATE_YEAR) + val style = when { + dateTime.toLocalDate() == now -> UiDateStyle.TIME + dateTime.year == now.year -> UiDateStyle.DATE_TIME + else -> UiDateStyle.DATE_TIME_YEAR } + return uiDateText(timestamp, style) } private class ActivityItemsPreviewProvider : PreviewParameterProvider { diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index 710d1e408f..693214c108 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -3,6 +3,8 @@ package to.bitkit.ext import org.junit.Test import to.bitkit.env.Env import to.bitkit.test.BaseUnitTest +import java.time.Instant +import java.time.ZoneId import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.test.assertEquals @@ -12,6 +14,12 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) class DateTimeExtTest : BaseUnitTest() { + private companion object { + val UTC: ZoneId = ZoneId.of("UTC") + val AFTERNOON: Instant = Instant.parse("2026-03-07T15:23:00Z") + val MIDNIGHT: Instant = Instant.parse("2026-03-07T00:15:00Z") + val NOON: Instant = Instant.parse("2026-03-07T12:05:00Z") + } @Test fun `toRelativeTimeString returns now for very recent timestamps`() { @@ -139,4 +147,81 @@ class DateTimeExtTest : BaseUnitTest() { assertEquals(resultWithDefaultParam, resultWithoutParam) } + + @Test + fun `TIME uses 24-hour clock when the device is set to 24-hour format`() { + val result = AFTERNOON.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = true)) + + assertEquals("15:23", result) + } + + @Test + fun `TIME uses 12-hour clock with meridiem when the device is set to 12-hour format`() { + val result = AFTERNOON.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = false)) + + assertEquals("3:23 PM", result) + } + + @Test + fun `TIME formats midnight without ambiguity in both clock formats`() { + val in24Hour = MIDNIGHT.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = true)) + val in12Hour = MIDNIGHT.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = false)) + + assertEquals("00:15", in24Hour) + assertEquals("12:15 AM", in12Hour) + } + + @Test + fun `TIME formats noon without ambiguity in both clock formats`() { + val in24Hour = NOON.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = true)) + val in12Hour = NOON.formattedInUtc(UiDateStyle.TIME.pattern(is24Hour = false)) + + assertEquals("12:05", in24Hour) + assertEquals("12:05 PM", in12Hour) + } + + @Test + fun `DATE is unaffected by the clock format`() { + val in24Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE.pattern(is24Hour = true)) + val in12Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE.pattern(is24Hour = false)) + + assertEquals("March 7", in24Hour) + assertEquals("March 7", in12Hour) + } + + @Test + fun `DATE_TIME keeps the date and applies the selected clock format`() { + val in24Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME.pattern(is24Hour = true)) + val in12Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME.pattern(is24Hour = false)) + + assertEquals("March 7, 15:23", in24Hour) + assertEquals("March 7, 3:23 PM", in12Hour) + } + + @Test + fun `DATE_TIME_YEAR keeps the year and applies the selected clock format`() { + val in24Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME_YEAR.pattern(is24Hour = true)) + val in12Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME_YEAR.pattern(is24Hour = false)) + + assertEquals("March 7 2026, 15:23", in24Hour) + assertEquals("March 7 2026, 3:23 PM", in12Hour) + } + + @Test + fun `DATE_TIME localizes the month name`() { + val pattern = UiDateStyle.DATE_TIME.pattern(is24Hour = true) + val result = AFTERNOON.formatted(pattern, Locale.GERMANY, UTC) + + assertEquals("März 7, 15:23", result) + } + + @Test + fun `formatted respects the supplied time zone`() { + val bucharest = ZoneId.of("Europe/Bucharest") + val result = AFTERNOON.formatted(UiDateStyle.TIME.pattern(is24Hour = true), Locale.US, bucharest) + + assertEquals("17:23", result) + } + + private fun Instant.formattedInUtc(pattern: String) = formatted(pattern, Locale.US, UTC) } diff --git a/changelog.d/next/677.fixed.md b/changelog.d/next/677.fixed.md new file mode 100644 index 0000000000..d9173d0e19 --- /dev/null +++ b/changelog.d/next/677.fixed.md @@ -0,0 +1 @@ +Activity timestamps now follow the device's 12/24-hour time setting instead of always using a 12-hour clock. From c54dc974b55d2200acce78a9262034b88e472b26 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Tue, 28 Jul 2026 11:43:48 +0300 Subject: [PATCH 2/7] chore: rename changelog fragment --- changelog.d/next/{677.fixed.md => 1111.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{677.fixed.md => 1111.fixed.md} (100%) diff --git a/changelog.d/next/677.fixed.md b/changelog.d/next/1111.fixed.md similarity index 100% rename from changelog.d/next/677.fixed.md rename to changelog.d/next/1111.fixed.md From 8ca5c3825b7942e738f70503cf805b4ed9a50af7 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Tue, 28 Jul 2026 12:02:52 +0300 Subject: [PATCH 3/7] fix: observe system clock format changes --- app/src/main/java/to/bitkit/ext/DateTime.kt | 9 ---- .../wallets/activity/ActivityDetailScreen.kt | 6 +-- .../activity/components/ActivityRow.kt | 10 ++-- .../ui/shared/effects/Is24HourFormat.kt | 53 +++++++++++++++++++ 4 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 4dfe4ecb40..29060e4bdf 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -1,6 +1,5 @@ package to.bitkit.ext -import android.content.Context import android.icu.text.DateFormat import android.icu.text.DisplayContext import android.icu.text.NumberFormat @@ -218,14 +217,6 @@ fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).a timeZone = java.util.TimeZone.getTimeZone("UTC") } -fun Context.uiDateText( - timestamp: ULong, - style: UiDateStyle, - locale: Locale = Locale.getDefault(), - zone: ZoneId = ZoneId.systemDefault(), -): String = Instant.ofEpochSecond(timestamp.toLong()) - .formatted(style.pattern(is24HourTimeFormat), locale, zone) - enum class UiDateStyle { TIME, DATE, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 535f07cbd0..8cc71024cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -64,7 +64,6 @@ import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue -import to.bitkit.ext.uiDateText import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat @@ -89,6 +88,7 @@ import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.animations.BalanceAnimations +import to.bitkit.ui.shared.effects.uiDateText import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.sheets.BoostTransactionSheet @@ -433,7 +433,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - BodySSB(text = LocalContext.current.uiDateText(timestamp, UiDateStyle.DATE)) + BodySSB(text = uiDateText(timestamp, UiDateStyle.DATE)) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() @@ -454,7 +454,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - BodySSB(text = LocalContext.current.uiDateText(timestamp, UiDateStyle.TIME)) + BodySSB(text = uiDateText(timestamp, UiDateStyle.TIME)) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 9bf39c6804..287c6cb935 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -1,6 +1,5 @@ package to.bitkit.ui.screens.wallets.activity.components -import android.content.Context import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -36,7 +35,6 @@ import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType -import to.bitkit.ext.uiDateText import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -54,6 +52,7 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.animations.BalanceAnimations +import to.bitkit.ui.shared.effects.uiDateText import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -139,7 +138,7 @@ fun ActivityRow( ) val context = LocalContext.current val subtitleText = when (item) { - is Activity.Lightning -> item.v1.message.ifEmpty { context.activityTimeText(timestamp) } + is Activity.Lightning -> item.v1.message.ifEmpty { activityTimeText(timestamp) } is Activity.Onchain -> { when { !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) @@ -162,7 +161,7 @@ fun ActivityRow( .replace("{duration}", duration) } - confirmed == true -> context.activityTimeText(timestamp) + confirmed == true -> activityTimeText(timestamp) else -> { val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) @@ -364,7 +363,8 @@ private fun AmountViewContent( } } -private fun Context.activityTimeText(timestamp: ULong): String { +@Composable +private fun activityTimeText(timestamp: ULong): String { val dateTime = Instant.ofEpochSecond(timestamp.toLong()).atZone(ZoneId.systemDefault()) val now = LocalDate.now() diff --git a/app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt b/app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt new file mode 100644 index 0000000000..a707b4c604 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt @@ -0,0 +1,53 @@ +package to.bitkit.ui.shared.effects + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import to.bitkit.ext.UiDateStyle +import to.bitkit.ext.formatted +import to.bitkit.ext.is24HourTimeFormat +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +@Composable +fun uiDateText( + timestamp: ULong, + style: UiDateStyle, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): String = Instant.ofEpochSecond(timestamp.toLong()) + .formatted(style.pattern(rememberIs24HourFormat()), locale, zone) + +@Composable +fun rememberIs24HourFormat(): Boolean { + val context = LocalContext.current + var is24Hour by remember(context) { mutableStateOf(context.is24HourTimeFormat) } + + DisposableEffect(context) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(receiverContext: Context?, intent: Intent?) = run { + is24Hour = context.is24HourTimeFormat + } + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_TIME_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + onDispose { context.unregisterReceiver(receiver) } + } + + return is24Hour +} From d595b70084e271ec7b6e49fbff160be62d519e3c Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Tue, 28 Jul 2026 13:39:11 +0300 Subject: [PATCH 4/7] refactor: move uiDateText to ui utils --- .../ui/screens/wallets/activity/ActivityDetailScreen.kt | 2 +- .../ui/screens/wallets/activity/components/ActivityRow.kt | 2 +- .../{shared/effects/Is24HourFormat.kt => utils/DateText.kt} | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) rename app/src/main/java/to/bitkit/ui/{shared/effects/Is24HourFormat.kt => utils/DateText.kt} (93%) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 8cc71024cc..1415f214df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -88,7 +88,6 @@ import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.animations.BalanceAnimations -import to.bitkit.ui.shared.effects.uiDateText import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.sheets.BoostTransactionSheet @@ -97,6 +96,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getScreenTitleRes +import to.bitkit.ui.utils.uiDateText import to.bitkit.viewmodels.ActivityDetailViewModel import to.bitkit.viewmodels.ActivityListViewModel diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 287c6cb935..7604c7a241 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -52,11 +52,11 @@ import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.UiConstants import to.bitkit.ui.shared.animations.BalanceAnimations -import to.bitkit.ui.shared.effects.uiDateText import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Shapes +import to.bitkit.ui.utils.uiDateText import java.time.Instant import java.time.LocalDate import java.time.ZoneId diff --git a/app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt b/app/src/main/java/to/bitkit/ui/utils/DateText.kt similarity index 93% rename from app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt rename to app/src/main/java/to/bitkit/ui/utils/DateText.kt index a707b4c604..909a066b48 100644 --- a/app/src/main/java/to/bitkit/ui/shared/effects/Is24HourFormat.kt +++ b/app/src/main/java/to/bitkit/ui/utils/DateText.kt @@ -1,4 +1,4 @@ -package to.bitkit.ui.shared.effects +package to.bitkit.ui.utils import android.content.BroadcastReceiver import android.content.Context @@ -29,8 +29,7 @@ fun uiDateText( .formatted(style.pattern(rememberIs24HourFormat()), locale, zone) @Composable -fun rememberIs24HourFormat(): Boolean { - val context = LocalContext.current +fun rememberIs24HourFormat(context: Context = LocalContext.current): Boolean { var is24Hour by remember(context) { mutableStateOf(context.is24HourTimeFormat) } DisposableEffect(context) { From 3e90024f4af0ca7ec7827c0f75db238ba7164f0a Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 11:11:11 +0300 Subject: [PATCH 5/7] refactor: provide clock format via composition local --- app/src/main/java/to/bitkit/ext/DateTime.kt | 12 +++++++----- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 ++ app/src/main/java/to/bitkit/ui/Locals.kt | 1 + app/src/main/java/to/bitkit/ui/utils/DateText.kt | 15 ++++++++++++--- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 29060e4bdf..7ede43af55 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,15 +39,17 @@ fun Clock.nowMs(): Long = now().toEpochMilliseconds() fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) +fun dateTimeFormatterOf( + pattern: String, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): DateTimeFormatter = DateTimeFormatter.ofPattern(pattern, locale).withZone(zone) + fun Instant.formatted( pattern: String = DatePattern.DATE_TIME, locale: Locale = Locale.getDefault(), zone: ZoneId = ZoneId.systemDefault(), -): String { - val dateTime = LocalDateTime.ofInstant(this, zone) - val formatter = DateTimeFormatter.ofPattern(pattern, locale) - return dateTime.format(formatter) -} +): String = dateTimeFormatterOf(pattern, locale, zone).format(this) fun ULong?.formatToString(pattern: String = DatePattern.DATE_TIME): String? { return this?.let { Instant.ofEpochSecond(toLong()).formatted(pattern) } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fe8e40f590..c9ae9f1b64 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -208,6 +208,7 @@ import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions +import to.bitkit.ui.utils.rememberIs24HourFormat import to.bitkit.ui.utils.rememberRequestNotificationPermission import to.bitkit.utils.Logger import to.bitkit.viewmodels.ActivityListViewModel @@ -399,6 +400,7 @@ fun ContentView( LocalSettingsViewModel provides settingsViewModel, LocalBackupsViewModel provides backupsViewModel, LocalDrawerState provides drawerState, + LocalIs24HourFormat provides rememberIs24HourFormat(), LocalBalances provides balance, LocalCurrencies provides currencies, ) { diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 5e669eb46b..c75a481a8a 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -21,6 +21,7 @@ val LocalCurrencies = compositionLocalOf { CurrencyState() } // Statics val LocalDrawerState = staticCompositionLocalOf { null } +val LocalIs24HourFormat = staticCompositionLocalOf { null } val LocalAppViewModel = staticCompositionLocalOf { null } val LocalWalletViewModel = staticCompositionLocalOf { null } val LocalBlocktankViewModel = staticCompositionLocalOf { null } diff --git a/app/src/main/java/to/bitkit/ui/utils/DateText.kt b/app/src/main/java/to/bitkit/ui/utils/DateText.kt index 909a066b48..8ae9fb36d3 100644 --- a/app/src/main/java/to/bitkit/ui/utils/DateText.kt +++ b/app/src/main/java/to/bitkit/ui/utils/DateText.kt @@ -13,8 +13,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import to.bitkit.ext.UiDateStyle -import to.bitkit.ext.formatted +import to.bitkit.ext.dateTimeFormatterOf import to.bitkit.ext.is24HourTimeFormat +import to.bitkit.ui.LocalIs24HourFormat import java.time.Instant import java.time.ZoneId import java.util.Locale @@ -25,8 +26,16 @@ fun uiDateText( style: UiDateStyle, locale: Locale = Locale.getDefault(), zone: ZoneId = ZoneId.systemDefault(), -): String = Instant.ofEpochSecond(timestamp.toLong()) - .formatted(style.pattern(rememberIs24HourFormat()), locale, zone) +): String { + val context = LocalContext.current + val is24Hour = LocalIs24HourFormat.current ?: context.is24HourTimeFormat + + val formatter = remember(style, is24Hour, locale, zone) { + dateTimeFormatterOf(style.pattern(is24Hour), locale, zone) + } + + return remember(formatter, timestamp) { formatter.format(Instant.ofEpochSecond(timestamp.toLong())) } +} @Composable fun rememberIs24HourFormat(context: Context = LocalContext.current): Boolean { From 0a102b0bc9f35b3db7b7ac76b472f46e635d1e3e Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 11:11:25 +0300 Subject: [PATCH 6/7] refactor: extract pure uiDateStyleFor into ext --- app/src/main/java/to/bitkit/ext/DateTime.kt | 14 +++++++ .../activity/components/ActivityRow.kt | 17 +-------- .../java/to/bitkit/ext/DateTimeExtTest.kt | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 7ede43af55..69c94349d5 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -29,6 +29,7 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime +import java.time.LocalDate as JavaLocalDate import kotlin.time.Instant as KInstant @OptIn(ExperimentalTime::class) @@ -219,6 +220,19 @@ fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).a timeZone = java.util.TimeZone.getTimeZone("UTC") } +fun uiDateStyleFor( + timestamp: ULong, + today: JavaLocalDate = JavaLocalDate.now(), + zone: ZoneId = ZoneId.systemDefault(), +): UiDateStyle { + val date = Instant.ofEpochSecond(timestamp.toLong()).atZone(zone).toLocalDate() + return when { + date == today -> UiDateStyle.TIME + date.year == today.year -> UiDateStyle.DATE_TIME + else -> UiDateStyle.DATE_TIME_YEAR + } +} + enum class UiDateStyle { TIME, DATE, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 7604c7a241..c274f95cc7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -28,13 +28,13 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType import to.bitkit.R -import to.bitkit.ext.UiDateStyle import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.timestamp import to.bitkit.ext.totalValue import to.bitkit.ext.txType +import to.bitkit.ext.uiDateStyleFor import to.bitkit.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PrimaryDisplay import to.bitkit.models.PubkyProfile @@ -57,9 +57,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Shapes import to.bitkit.ui.utils.uiDateText -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId @Suppress("CyclomaticComplexMethod") @Composable @@ -364,17 +361,7 @@ private fun AmountViewContent( } @Composable -private fun activityTimeText(timestamp: ULong): String { - val dateTime = Instant.ofEpochSecond(timestamp.toLong()).atZone(ZoneId.systemDefault()) - val now = LocalDate.now() - - val style = when { - dateTime.toLocalDate() == now -> UiDateStyle.TIME - dateTime.year == now.year -> UiDateStyle.DATE_TIME - else -> UiDateStyle.DATE_TIME_YEAR - } - return uiDateText(timestamp, style) -} +private fun activityTimeText(timestamp: ULong): String = uiDateText(timestamp, uiDateStyleFor(timestamp)) private class ActivityItemsPreviewProvider : PreviewParameterProvider { override val values: Sequence get() = previewActivityItems.asSequence() diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index 693214c108..5332e70919 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -4,6 +4,7 @@ import org.junit.Test import to.bitkit.env.Env import to.bitkit.test.BaseUnitTest import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.util.Locale import java.util.concurrent.TimeUnit @@ -223,5 +224,42 @@ class DateTimeExtTest : BaseUnitTest() { assertEquals("17:23", result) } + @Test + fun `uiDateStyleFor returns TIME for a timestamp from today`() { + val today = LocalDate.of(2026, 3, 7) + + val style = uiDateStyleFor(AFTERNOON.epochSecond.toULong(), today, UTC) + + assertEquals(UiDateStyle.TIME, style) + } + + @Test + fun `uiDateStyleFor returns DATE_TIME for an earlier day in the same year`() { + val today = LocalDate.of(2026, 12, 31) + + val style = uiDateStyleFor(AFTERNOON.epochSecond.toULong(), today, UTC) + + assertEquals(UiDateStyle.DATE_TIME, style) + } + + @Test + fun `uiDateStyleFor returns DATE_TIME_YEAR for a timestamp from a previous year`() { + val today = LocalDate.of(2027, 1, 1) + + val style = uiDateStyleFor(AFTERNOON.epochSecond.toULong(), today, UTC) + + assertEquals(UiDateStyle.DATE_TIME_YEAR, style) + } + + @Test + fun `uiDateStyleFor resolves the day in the supplied time zone`() { + val lateEvening = Instant.parse("2026-03-07T23:30:00Z") + val today = LocalDate.of(2026, 3, 7) + val bucharest = ZoneId.of("Europe/Bucharest") + + assertEquals(UiDateStyle.TIME, uiDateStyleFor(lateEvening.epochSecond.toULong(), today, UTC)) + assertEquals(UiDateStyle.DATE_TIME, uiDateStyleFor(lateEvening.epochSecond.toULong(), today, bucharest)) + } + private fun Instant.formattedInUtc(pattern: String) = formatted(pattern, Locale.US, UTC) } From 3e0713edc5de87526b3848a0585d2cdb4142b941 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 14:56:53 +0300 Subject: [PATCH 7/7] refactor: simplify clock format local --- app/src/main/java/to/bitkit/ui/Locals.kt | 2 +- app/src/main/java/to/bitkit/ui/utils/DateText.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index c75a481a8a..ade0f8385e 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -18,10 +18,10 @@ import to.bitkit.viewmodels.WalletViewModel // Locals val LocalBalances = compositionLocalOf { BalanceState() } val LocalCurrencies = compositionLocalOf { CurrencyState() } +val LocalIs24HourFormat = compositionLocalOf { false } // Statics val LocalDrawerState = staticCompositionLocalOf { null } -val LocalIs24HourFormat = staticCompositionLocalOf { null } val LocalAppViewModel = staticCompositionLocalOf { null } val LocalWalletViewModel = staticCompositionLocalOf { null } val LocalBlocktankViewModel = staticCompositionLocalOf { null } diff --git a/app/src/main/java/to/bitkit/ui/utils/DateText.kt b/app/src/main/java/to/bitkit/ui/utils/DateText.kt index 8ae9fb36d3..b63430af5a 100644 --- a/app/src/main/java/to/bitkit/ui/utils/DateText.kt +++ b/app/src/main/java/to/bitkit/ui/utils/DateText.kt @@ -27,8 +27,7 @@ fun uiDateText( locale: Locale = Locale.getDefault(), zone: ZoneId = ZoneId.systemDefault(), ): String { - val context = LocalContext.current - val is24Hour = LocalIs24HourFormat.current ?: context.is24HourTimeFormat + val is24Hour = LocalIs24HourFormat.current val formatter = remember(style, is24Hour, locale, zone) { dateTimeFormatterOf(style.pattern(is24Hour), locale, zone)