@@ -30,22 +30,24 @@ import androidx.compose.foundation.layout.ColumnScope
3030import androidx.compose.foundation.layout.Row
3131import androidx.compose.foundation.layout.Spacer
3232import androidx.compose.foundation.layout.fillMaxHeight
33- import androidx.compose.foundation.layout.fillMaxSize
3433import androidx.compose.foundation.layout.fillMaxWidth
3534import androidx.compose.foundation.layout.heightIn
3635import androidx.compose.foundation.layout.height
3736import androidx.compose.foundation.layout.padding
3837import androidx.compose.foundation.layout.size
3938import androidx.compose.foundation.lazy.LazyColumn
4039import androidx.compose.foundation.lazy.items
40+ import androidx.compose.foundation.lazy.rememberLazyListState
4141import androidx.compose.foundation.shape.CircleShape
4242import androidx.compose.foundation.shape.RoundedCornerShape
4343import androidx.compose.material3.DropdownMenu
4444import androidx.compose.material3.DropdownMenuItem
4545import androidx.compose.material3.Icon
4646import androidx.compose.material3.Text
4747import androidx.compose.runtime.Composable
48+ import androidx.compose.runtime.DisposableEffect
4849import androidx.compose.runtime.LaunchedEffect
50+ import androidx.compose.runtime.derivedStateOf
4951import androidx.compose.runtime.getValue
5052import androidx.compose.runtime.mutableStateOf
5153import androidx.compose.runtime.remember
@@ -54,6 +56,7 @@ import androidx.compose.ui.Alignment
5456import androidx.compose.ui.Modifier
5557import androidx.compose.ui.draw.clip
5658import androidx.compose.ui.graphics.Color
59+ import androidx.compose.ui.platform.LocalContext
5760import androidx.compose.ui.res.painterResource
5861import androidx.compose.ui.res.stringResource
5962import androidx.compose.ui.tooling.preview.Preview
@@ -92,6 +95,11 @@ import org.dash.wallet.common.ui.components.NavBarClose
9295import org.dash.wallet.common.ui.components.Style
9396import org.dash.wallet.common.ui.dialogs.AdaptiveDialog
9497import org.dash.wallet.common.ui.dialogs.ComposeBottomSheet
98+ import java.time.Instant
99+ import java.time.LocalDate
100+ import java.time.ZoneId
101+ import java.time.format.DateTimeFormatter
102+ import java.util.Locale
95103
96104@AndroidEntryPoint
97105class DashPayUserBottomSheet : ComposeBottomSheet () {
@@ -179,14 +187,26 @@ class DashPayUserBottomSheet : ComposeBottomSheet() {
179187 }
180188 },
181189 onFilterSelected = viewModel::setFilter,
182- isSentTransaction = viewModel::isSentTransaction
190+ isSentTransaction = viewModel::isSentTransaction,
191+ onSheetDraggableChanged = ::setSheetDraggable
183192 )
184193 }
185194
186195 private fun notifyContactChange () {
187196 setFragmentResult(REQUEST_KEY , bundleOf(KEY_CHANGED to true ))
188197 }
189198
199+ // The activity list is a Compose LazyColumn nested inside a Material BottomSheetDialog.
200+ // BottomSheetBehavior's drag and the list's scroll both want vertical gestures, so we let the
201+ // list own them while it has content above (drag locked) and only re-enable the sheet drag
202+ // (so a downward swipe collapses/dismisses) once the list is scrolled to its very top.
203+ private fun setSheetDraggable (draggable : Boolean ) {
204+ val sheet = (dialog as ? BottomSheetDialog )
205+ ?.findViewById<FrameLayout >(com.google.android.material.R .id.design_bottom_sheet)
206+ ? : return
207+ BottomSheetBehavior .from(sheet).isDraggable = draggable
208+ }
209+
190210 private fun applyAutoExpandIfNeeded (
191211 type : UsernameSearchResult .Type ? ,
192212 notificationCount : Int
@@ -303,7 +323,8 @@ private fun DashPayUserContent(
303323 onPayClick : () -> Unit ,
304324 onNotificationClick : (NotificationItem ) -> Unit ,
305325 onFilterSelected : (NotificationFilter ) -> Unit = {},
306- isSentTransaction : (Transaction ) -> Boolean = { false }
326+ isSentTransaction : (Transaction ) -> Boolean = { false },
327+ onSheetDraggableChanged : (Boolean ) -> Unit = {}
307328) {
308329 val userData = state.userData
309330 Column (
@@ -346,7 +367,8 @@ private fun DashPayUserContent(
346367 isFullScreen = isFullScreen,
347368 onFilterSelected = onFilterSelected,
348369 onNotificationClick = onNotificationClick,
349- isSentTransaction = isSentTransaction
370+ isSentTransaction = isSentTransaction,
371+ onSheetDraggableChanged = onSheetDraggableChanged
350372 )
351373 }
352374
@@ -481,23 +503,15 @@ private fun RequestReceivedCard(
481503 }
482504}
483505
484- // CONTACT_ESTABLISHED emits two contact rows for the same user (the established record + an
485- // "invitationOfEstablished" marker). Both have the same `getId()`, which crashes LazyColumn.
486- // Compose the flag in so the keys stay unique without touching the shared `getId()` contract
487- // used by NotificationsAdapter.
488- private fun NotificationItem.lazyKey (): String = when (this ) {
489- is NotificationItemContact -> " contact:${getId()} :${isInvitationOfEstablished} "
490- else -> getId()
491- }
492-
493506@Composable
494507private fun ColumnScope.ActivitySection (
495508 notifications : List <NotificationItem >,
496509 activeFilter : NotificationFilter ,
497510 isFullScreen : Boolean ,
498511 onFilterSelected : (NotificationFilter ) -> Unit ,
499512 onNotificationClick : (NotificationItem ) -> Unit ,
500- isSentTransaction : (Transaction ) -> Boolean
513+ isSentTransaction : (Transaction ) -> Boolean ,
514+ onSheetDraggableChanged : (Boolean ) -> Unit = {}
501515) {
502516 // In full-screen mode, the section claims all remaining vertical space so the inner
503517 // list can scroll inside it. In wrap_content mode, the section measures to its content
@@ -531,40 +545,123 @@ private fun ColumnScope.ActivitySection(
531545 onFilterSelected = onFilterSelected
532546 )
533547 }
534- val containerModifier = if (isFullScreen) {
548+ // Group the (already date-sorted) notifications by calendar day; each day renders as its
549+ // own rounded card with a header (date label on the left, weekday on the right).
550+ val groups = remember(notifications) { groupNotificationsByDay(notifications) }
551+ val listModifier = if (isFullScreen) {
535552 Modifier
536- .fillMaxSize()
537- .clip(RoundedCornerShape (20 .dp))
538- .background(MyTheme .Colors .backgroundSecondary)
539- .padding(6 .dp)
553+ .fillMaxWidth()
554+ .weight(1f , fill = true )
540555 } else {
541556 Modifier
542557 .fillMaxWidth()
543- .clip(RoundedCornerShape (20 .dp))
544- .background(MyTheme .Colors .backgroundSecondary)
545- .padding(6 .dp)
558+ .heightIn(max = 500 .dp)
546559 }
547- Column (modifier = containerModifier) {
548- val listModifier = if (isFullScreen) {
549- Modifier .fillMaxSize()
550- } else {
551- Modifier
552- .fillMaxWidth()
553- .heightIn(max = 500 .dp)
560+ // Let the list own vertical gestures while it has content scrolled above the top, and only
561+ // hand the sheet back its drag (so a downward swipe can collapse/dismiss) once the list is
562+ // resting at its very top. This avoids the sheet and the LazyColumn fighting over the drag.
563+ val listState = rememberLazyListState()
564+ val listAtTop by remember {
565+ derivedStateOf {
566+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
554567 }
555- LazyColumn (modifier = listModifier) {
556- items(notifications, key = { it.lazyKey() }) { item ->
557- NotificationRow (
558- item = item,
559- isSentTransaction = isSentTransaction,
560- onClick = { onNotificationClick(item) }
561- )
562- }
568+ }
569+ LaunchedEffect (listAtTop) {
570+ onSheetDraggableChanged(listAtTop)
571+ }
572+ DisposableEffect (Unit ) {
573+ onDispose { onSheetDraggableChanged(true ) }
574+ }
575+ LazyColumn (
576+ state = listState,
577+ modifier = listModifier,
578+ verticalArrangement = Arrangement .spacedBy(10 .dp)
579+ ) {
580+ items(groups, key = { it.date.toString() }) { group ->
581+ DayGroupCard (
582+ group = group,
583+ isSentTransaction = isSentTransaction,
584+ onNotificationClick = onNotificationClick
585+ )
563586 }
564587 }
565588 }
566589}
567590
591+ // A day's worth of activity items, used to render grouped, date-headed cards.
592+ private data class NotificationDayGroup (
593+ val date : LocalDate ,
594+ val items : List <NotificationItem >
595+ )
596+
597+ // groupBy preserves the encounter order of both keys and values, so an input that is already
598+ // sorted newest-first stays newest-first; the trailing sort is a defensive no-op.
599+ private fun groupNotificationsByDay (items : List <NotificationItem >): List <NotificationDayGroup > {
600+ val zone = ZoneId .systemDefault()
601+ return items
602+ .groupBy { Instant .ofEpochMilli(it.getDate()).atZone(zone).toLocalDate() }
603+ .map { (date, dayItems) -> NotificationDayGroup (date, dayItems) }
604+ .sortedByDescending { it.date }
605+ }
606+
607+ /* * "Today", "Yesterday", or a locale-ordered date ("2 May" / "May 2"), with the year when not current. */
608+ @Composable
609+ private fun dayLabel (date : LocalDate ): String {
610+ val now = LocalDate .now()
611+ return when {
612+ date == now -> stringResource(R .string.today)
613+ date == now.minusDays(1 ) -> stringResource(R .string.yesterday)
614+ else -> {
615+ val locale = Locale .getDefault()
616+ val skeleton = if (date.year == now.year) " MMMMd" else " yMMMMd"
617+ val pattern = android.text.format.DateFormat .getBestDateTimePattern(locale, skeleton)
618+ DateTimeFormatter .ofPattern(pattern, locale).format(date)
619+ }
620+ }
621+ }
622+
623+ @Composable
624+ private fun DayGroupCard (
625+ group : NotificationDayGroup ,
626+ isSentTransaction : (Transaction ) -> Boolean ,
627+ onNotificationClick : (NotificationItem ) -> Unit
628+ ) {
629+ Column (
630+ modifier = Modifier
631+ .fillMaxWidth()
632+ .clip(RoundedCornerShape (20 .dp))
633+ .background(MyTheme .Colors .backgroundSecondary)
634+ .padding(6 .dp),
635+ verticalArrangement = Arrangement .spacedBy(4 .dp)
636+ ) {
637+ Row (
638+ modifier = Modifier
639+ .fillMaxWidth()
640+ .padding(10 .dp),
641+ horizontalArrangement = Arrangement .SpaceBetween ,
642+ verticalAlignment = Alignment .CenterVertically
643+ ) {
644+ Text (
645+ text = dayLabel(group.date),
646+ style = MyTheme .CaptionMedium ,
647+ color = MyTheme .Colors .textPrimary
648+ )
649+ Text (
650+ text = DateTimeFormatter .ofPattern(" EEEE" , Locale .getDefault()).format(group.date),
651+ style = MyTheme .Caption ,
652+ color = MyTheme .Colors .textSecondary
653+ )
654+ }
655+ group.items.forEach { item ->
656+ NotificationRow (
657+ item = item,
658+ isSentTransaction = isSentTransaction,
659+ onClick = { onNotificationClick(item) }
660+ )
661+ }
662+ }
663+ }
664+
568665@Composable
569666private fun FilterButton (
570667 activeFilter : NotificationFilter ,
@@ -645,6 +742,10 @@ private fun NotificationRow(
645742 isSentTransaction : (Transaction ) -> Boolean ,
646743 onClick : () -> Unit
647744) {
745+ // The day is conveyed by the group header; rows show only the time of day (e.g. "9:40 AM"),
746+ // localized and honoring the system 12/24-hour setting.
747+ val context = LocalContext .current
748+ val timeText = DateUtils .formatDateTime(context, item.getDate(), DateUtils .FORMAT_SHOW_TIME )
648749 Row (
649750 modifier = Modifier
650751 .fillMaxWidth()
@@ -707,11 +808,7 @@ private fun NotificationRow(
707808 color = MyTheme .Colors .textPrimary
708809 )
709810 Text (
710- text = DateUtils .getRelativeTimeSpanString(
711- item.getDate(),
712- System .currentTimeMillis(),
713- DateUtils .MINUTE_IN_MILLIS
714- ).toString(),
811+ text = timeText,
715812 style = MyTheme .Typography .BodyMedium ,
716813 color = MyTheme .Colors .textSecondary
717814 )
@@ -737,11 +834,7 @@ private fun NotificationRow(
737834 color = MyTheme .Colors .textPrimary
738835 )
739836 Text (
740- text = DateUtils .getRelativeTimeSpanString(
741- item.getDate(),
742- System .currentTimeMillis(),
743- DateUtils .MINUTE_IN_MILLIS
744- ).toString(),
837+ text = timeText,
745838 style = MyTheme .Typography .BodyMedium ,
746839 color = MyTheme .Colors .textSecondary
747840 )
@@ -822,13 +915,21 @@ private fun previewUserData(type: UsernameSearchResult.Type): UsernameSearchResu
822915}
823916
824917private fun previewNotifications (profile : DashPayProfile ): List <NotificationItem > {
825- val result = UsernameSearchResult (
826- profile.username,
827- profile,
828- previewContactRequest(" preview-self-id" , profile.userId),
829- null
830- )
831- return listOf (NotificationItemContact (result))
918+ val me = " preview-self-id"
919+ val them = profile.userId
920+ val dayMillis = 24L * 60 * 60 * 1000
921+ val now = System .currentTimeMillis()
922+ // Three sent requests spread across today, yesterday and a few days back so the
923+ // day-grouping (Today / Yesterday / dated) is exercised by the preview.
924+ return listOf (
925+ now - 60_000L ,
926+ now - dayMillis,
927+ now - 5 * dayMillis
928+ ).map { ts ->
929+ NotificationItemContact (
930+ UsernameSearchResult (profile.username, profile, previewContactRequest(me, them, ts), null )
931+ )
932+ }
832933}
833934
834935@Composable
0 commit comments