Skip to content

Commit 4c9494d

Browse files
feat: update about-me activity list UI to designs (#1500)
* feat: group transactions on DashPayUserBottomSheet * fix: support scrolling * fix: onDispose fix
1 parent 299be32 commit 4c9494d

1 file changed

Lines changed: 155 additions & 54 deletions

File tree

wallet/src/de/schildbach/wallet/ui/dashpay/user/DashPayUserBottomSheet.kt

Lines changed: 155 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,24 @@ import androidx.compose.foundation.layout.ColumnScope
3030
import androidx.compose.foundation.layout.Row
3131
import androidx.compose.foundation.layout.Spacer
3232
import androidx.compose.foundation.layout.fillMaxHeight
33-
import androidx.compose.foundation.layout.fillMaxSize
3433
import androidx.compose.foundation.layout.fillMaxWidth
3534
import androidx.compose.foundation.layout.heightIn
3635
import androidx.compose.foundation.layout.height
3736
import androidx.compose.foundation.layout.padding
3837
import androidx.compose.foundation.layout.size
3938
import androidx.compose.foundation.lazy.LazyColumn
4039
import androidx.compose.foundation.lazy.items
40+
import androidx.compose.foundation.lazy.rememberLazyListState
4141
import androidx.compose.foundation.shape.CircleShape
4242
import androidx.compose.foundation.shape.RoundedCornerShape
4343
import androidx.compose.material3.DropdownMenu
4444
import androidx.compose.material3.DropdownMenuItem
4545
import androidx.compose.material3.Icon
4646
import androidx.compose.material3.Text
4747
import androidx.compose.runtime.Composable
48+
import androidx.compose.runtime.DisposableEffect
4849
import androidx.compose.runtime.LaunchedEffect
50+
import androidx.compose.runtime.derivedStateOf
4951
import androidx.compose.runtime.getValue
5052
import androidx.compose.runtime.mutableStateOf
5153
import androidx.compose.runtime.remember
@@ -54,6 +56,7 @@ import androidx.compose.ui.Alignment
5456
import androidx.compose.ui.Modifier
5557
import androidx.compose.ui.draw.clip
5658
import androidx.compose.ui.graphics.Color
59+
import androidx.compose.ui.platform.LocalContext
5760
import androidx.compose.ui.res.painterResource
5861
import androidx.compose.ui.res.stringResource
5962
import androidx.compose.ui.tooling.preview.Preview
@@ -92,6 +95,11 @@ import org.dash.wallet.common.ui.components.NavBarClose
9295
import org.dash.wallet.common.ui.components.Style
9396
import org.dash.wallet.common.ui.dialogs.AdaptiveDialog
9497
import 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
97105
class 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
494507
private 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
569666
private 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

824917
private 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

Comments
 (0)