diff --git a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/RecentsBottomSheet.kt b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/RecentsBottomSheet.kt index b5d54c366..d40156398 100644 --- a/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/RecentsBottomSheet.kt +++ b/android/features/asset_select/presents/src/main/kotlin/com/gemwallet/android/features/asset_select/presents/views/RecentsBottomSheet.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -18,32 +17,21 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import com.gemwallet.android.ext.toIdentifier import com.gemwallet.android.features.asset_select.viewmodels.models.RecentsEmptyState import com.gemwallet.android.features.asset_select.viewmodels.models.RecentsSheetUIModel import com.gemwallet.android.ui.components.empty.EmptyContentType import com.gemwallet.android.ui.components.empty.EmptyContentView -import com.gemwallet.android.model.RecentAsset import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.SearchBar import com.gemwallet.android.ui.components.list_item.AssetListItem -import com.gemwallet.android.ui.components.list_item.SubheaderItem -import com.gemwallet.android.ui.components.list_item.property.itemsPositioned +import com.gemwallet.android.ui.components.list_item.dateGroupedList import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.theme.paddingDefault -import com.gemwallet.android.ui.theme.paddingHalfSmall import com.wallet.core.primitives.AssetId -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale @Composable fun RecentsBottomSheet( @@ -54,10 +42,6 @@ fun RecentsBottomSheet( onClear: () -> Unit, onSelect: (AssetId) -> Unit, ) { - val todayLabel = stringResource(R.string.date_today) - val yesterdayLabel = stringResource(R.string.date_yesterday) - val locale = LocalConfiguration.current.locales[0] - ModalBottomSheet( isVisible = isVisible, onDismissRequest = onDismissRequest, @@ -101,12 +85,17 @@ fun RecentsBottomSheet( if (empty != null) { RecentsEmptyStateView(empty) } else { - val sections = remember(uiModel.items, locale, todayLabel, yesterdayLabel) { - buildDateSections(uiModel.items, locale, todayLabel, yesterdayLabel) - } LazyColumn(modifier = Modifier.fillMaxSize()) { - sections.forEach { section -> - recentsSection(section, onSelect) + dateGroupedList( + items = uiModel.items.sortedByDescending { it.addedAt }, + createdAt = { it.addedAt }, + key = { _, recent -> "${recent.addedAt}-${recent.asset.id.toIdentifier()}" }, + ) { position, recent -> + AssetListItem( + asset = recent.asset, + listPosition = position, + modifier = Modifier.clickable { onSelect(recent.asset.id) }, + ) } } } @@ -114,25 +103,6 @@ fun RecentsBottomSheet( } } -private fun LazyListScope.recentsSection( - section: RecentsDateSection, - onSelect: (AssetId) -> Unit, -) { - item(key = "header-${section.id}") { - SubheaderItem(section.title) - } - itemsPositioned( - section.items, - key = { _, recent -> "${section.id}-${recent.asset.id.toIdentifier()}" }, - ) { position, recent -> - AssetListItem( - asset = recent.asset, - listPosition = position, - modifier = Modifier.clickable { onSelect(recent.asset.id) }, - ) - } -} - @Composable private fun RecentsEmptyStateView(state: RecentsEmptyState) { val type = when (state) { @@ -141,42 +111,3 @@ private fun RecentsEmptyStateView(state: RecentsEmptyState) { } EmptyContentView(type = type, modifier = Modifier.fillMaxSize()) } - -private data class RecentsDateSection( - val id: String, - val title: String, - val items: List, -) - -private fun buildDateSections( - items: List, - locale: Locale, - todayLabel: String, - yesterdayLabel: String, -): List { - if (items.isEmpty()) return emptyList() - val zone = ZoneId.systemDefault() - val today = LocalDate.now(zone) - val yesterday = today.minusDays(1) - val longFormatter = DateTimeFormatter - .ofLocalizedDate(FormatStyle.LONG) - .withLocale(locale) - - return items.groupBy { recent -> - Instant.ofEpochMilli(recent.addedAt).atZone(zone).toLocalDate() - } - .entries - .sortedByDescending { it.key } - .map { (date, values) -> - val title = when (date) { - today -> todayLabel - yesterday -> yesterdayLabel - else -> longFormatter.format(date) - } - RecentsDateSection( - id = date.toString(), - title = title, - items = values.sortedByDescending { it.addedAt }, - ) - } -} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DateGroupedList.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DateGroupedList.kt index 9a9ea615e..1cfbb756c 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DateGroupedList.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/DateGroupedList.kt @@ -1,18 +1,21 @@ package com.gemwallet.android.ui.components.list_item -import android.icu.util.Calendar -import android.text.format.DateUtils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.list_item.property.itemsPositioned +import com.gemwallet.android.ui.format.SectionDateFormatter import com.gemwallet.android.ui.models.ListPosition -import java.text.DateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId @OptIn(ExperimentalFoundationApi::class) fun LazyListScope.dateGroupedList( @@ -21,28 +24,20 @@ fun LazyListScope.dateGroupedList( key: (Int, T) -> Any, itemContent: @Composable LazyItemScope.(ListPosition, T) -> Unit, ) { - val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM) - val calendar = Calendar.getInstance() - - items.groupBy { item -> - calendar.timeInMillis = createdAt(item) - calendar[Calendar.MILLISECOND] = 999 - calendar[Calendar.SECOND] = 59 - calendar[Calendar.MINUTE] = 59 - calendar[Calendar.HOUR_OF_DAY] = 23 - calendar.time.time - }.forEach { (timestamp, entries) -> - stickyHeader { - val title = if (DateUtils.isToday(timestamp) || DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS)) { - DateUtils.getRelativeTimeSpanString(timestamp, System.currentTimeMillis(), DateUtils.DAY_IN_MILLIS).toString() - } else { - dateFormat.format(Date(timestamp)) + val zone = ZoneId.systemDefault() + items.groupBy { Instant.ofEpochMilli(createdAt(it)).atZone(zone).toLocalDate() } + .forEach { (date, entries) -> + stickyHeader { + val todayLabel = stringResource(R.string.date_today) + val yesterdayLabel = stringResource(R.string.date_yesterday) + val formatter = remember(todayLabel, yesterdayLabel) { + SectionDateFormatter(todayLabel, yesterdayLabel) + } + SubheaderItem( + title = formatter.format(date, LocalConfiguration.current.locales[0]), + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) } - SubheaderItem( - title = title, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) + itemsPositioned(entries, key = key, itemContent = itemContent) } - itemsPositioned(entries, key = key, itemContent = itemContent) - } } diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/format/SectionDateFormatter.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/format/SectionDateFormatter.kt new file mode 100644 index 000000000..5112e61af --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/format/SectionDateFormatter.kt @@ -0,0 +1,26 @@ +package com.gemwallet.android.ui.format + +import java.time.Clock +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class SectionDateFormatter( + private val todayLabel: String, + private val yesterdayLabel: String, + private val clock: Clock = Clock.systemDefaultZone(), +) { + + fun format(date: LocalDate, locale: Locale): String { + val today = LocalDate.now(clock) + return when (date) { + today -> todayLabel + today.minusDays(1) -> yesterdayLabel + else -> DateTimeFormatter + .ofLocalizedDate(FormatStyle.LONG) + .withLocale(locale) + .format(date) + } + } +} diff --git a/android/ui/src/test/kotlin/com/gemwallet/android/ui/format/SectionDateFormatterTest.kt b/android/ui/src/test/kotlin/com/gemwallet/android/ui/format/SectionDateFormatterTest.kt new file mode 100644 index 000000000..549d8673d --- /dev/null +++ b/android/ui/src/test/kotlin/com/gemwallet/android/ui/format/SectionDateFormatterTest.kt @@ -0,0 +1,37 @@ +package com.gemwallet.android.ui.format + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.Locale + +class SectionDateFormatterTest { + + private val zone = ZoneId.of("UTC") + private val clock = Clock.fixed( + ZonedDateTime.of(2026, 5, 12, 10, 0, 0, 0, zone).toInstant(), + zone, + ) + private val locale = Locale.US + private val formatter = SectionDateFormatter( + todayLabel = TODAY, + yesterdayLabel = YESTERDAY, + clock = clock, + ) + + @Test + fun test_format() { + assertEquals(TODAY, formatter.format(LocalDate.of(2026, 5, 12), locale)) + assertEquals(YESTERDAY, formatter.format(LocalDate.of(2026, 5, 11), locale)) + assertEquals("May 10, 2026", formatter.format(LocalDate.of(2026, 5, 10), locale)) + assertEquals("March 5, 2026", formatter.format(LocalDate.of(2026, 3, 5), locale)) + } + + companion object { + private const val TODAY = "Today" + private const val YESTERDAY = "Yesterday" + } +}