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..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) @@ -39,11 +40,17 @@ 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) - return dateTime.format(formatter) -} +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 = dateTimeFormatterOf(pattern, locale, zone).format(this) fun ULong?.formatToString(pattern: String = DatePattern.DATE_TIME): String? { return this?.let { Instant.ofEpochSecond(toLong()).formatted(pattern) } @@ -213,13 +220,46 @@ 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, + 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/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..ade0f8385e 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -18,6 +18,7 @@ import to.bitkit.viewmodels.WalletViewModel // Locals val LocalBalances = compositionLocalOf { BalanceState() } val LocalCurrencies = compositionLocalOf { CurrencyState() } +val LocalIs24HourFormat = compositionLocalOf { false } // Statics val LocalDrawerState = staticCompositionLocalOf { null } 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..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 @@ -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,8 +63,6 @@ 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.models.FeeRate.Companion.getFeeShortDescription import to.bitkit.models.PubkyProfile @@ -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 @@ -433,7 +433,7 @@ private fun ActivityDetailContent( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - BodySSB(text = timestamp.toActivityItemDate()) + 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 = timestamp.toActivityItemTime()) + 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 a26a324811..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,14 +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.DatePattern -import to.bitkit.ext.formatted 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 +56,7 @@ 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 java.time.Instant -import java.time.LocalDate -import java.time.ZoneId +import to.bitkit.ui.utils.uiDateText @Suppress("CyclomaticComplexMethod") @Composable @@ -138,7 +135,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 { activityTimeText(timestamp) } is Activity.Onchain -> { when { !item.v1.doesExist -> stringResource(R.string.wallet__activity_removed) @@ -161,7 +158,7 @@ fun ActivityRow( .replace("{duration}", duration) } - confirmed == true -> formattedTime(timestamp) + confirmed == true -> activityTimeText(timestamp) else -> { val feeDescription = context.getFeeShortDescription(item.v1.feeRate, feeRates) @@ -363,19 +360,8 @@ private fun AmountViewContent( } } -private fun formattedTime(timestamp: ULong): String { - val instant = Instant.ofEpochSecond(timestamp.toLong()) - val dateTime = instant.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) - } -} +@Composable +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/main/java/to/bitkit/ui/utils/DateText.kt b/app/src/main/java/to/bitkit/ui/utils/DateText.kt new file mode 100644 index 0000000000..b63430af5a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/DateText.kt @@ -0,0 +1,60 @@ +package to.bitkit.ui.utils + +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.dateTimeFormatterOf +import to.bitkit.ext.is24HourTimeFormat +import to.bitkit.ui.LocalIs24HourFormat +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 { + val is24Hour = LocalIs24HourFormat.current + + 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 { + 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 +} diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index 710d1e408f..5332e70919 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -3,6 +3,9 @@ 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.LocalDate +import java.time.ZoneId import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.test.assertEquals @@ -12,6 +15,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 +148,118 @@ 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) + } + + @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) } diff --git a/changelog.d/next/1111.fixed.md b/changelog.d/next/1111.fixed.md new file mode 100644 index 0000000000..d9173d0e19 --- /dev/null +++ b/changelog.d/next/1111.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.