diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index d011c2a9c..705d1ed4c 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -1,8 +1,28 @@ package to.bitkit.ext import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.PaymentType fun Activity.rawId(): String = when (this) { is Activity.Lightning -> v1.id is Activity.Onchain -> v1.id } + +/** + * Calculates the total value of an activity based on its type. + * + * For `Lightning` activity, the total value = `value + fee`. + * + * For `Onchain` activity: + * - If it is a send, the total value = `value + fee`. + * - Otherwise it's equal to `value`. + * + * @return The total value as an `ULong`. + */ +fun Activity.totalValue() = when(this) { + is Activity.Lightning -> v1.value + (v1.fee ?: 0u) + is Activity.Onchain -> when (v1.txType) { + PaymentType.SENT -> v1.value + v1.fee + else -> v1.value + } +} diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 2cfa9a2d2..9357c3fd0 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -252,11 +252,11 @@ class ActivityService( txType = payment.direction.toPaymentType(), status = state, value = payment.amountSats ?: 0u, - fee = null, // TODO - invoice = "lnbc123", // TODO + fee = (payment.feePaidMsat ?: 0u) / 1000u, + invoice = "lnbc123_todo", // TODO message = "", timestamp = payment.latestUpdateTimestamp, - preimage = null, + preimage = kind.preimage, createdAt = payment.latestUpdateTimestamp, updatedAt = payment.latestUpdateTimestamp, ) @@ -370,7 +370,8 @@ class ActivityService( "Gift for mom", "Split dinner bill", "Monthly rent", - "Gym membership" + "Gym membership", + "Very long invoice message to test truncation in list", ) repeat(count) { i -> diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index 5789cf225..02f96de49 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -350,6 +350,8 @@ fun CaptionB( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, textAlign: TextAlign = TextAlign.Start, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, ) { Text( text = text, @@ -363,6 +365,8 @@ fun CaptionB( textAlign = textAlign, ), modifier = modifier, + maxLines = maxLines, + overflow = overflow, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 43dcecbf2..7b7c5fa0d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -60,7 +60,7 @@ import to.bitkit.ui.navigateToTransferSavingsIntro import to.bitkit.ui.navigateToTransferSpendingAmount import to.bitkit.ui.navigateToTransferSpendingIntro import to.bitkit.ui.scaffold.AppScaffold -import to.bitkit.ui.screens.wallets.activity.HomeActivityList +import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.AllActivityScreen import to.bitkit.ui.screens.wallets.activity.DateRangeSelectorSheet import to.bitkit.ui.screens.wallets.activity.TagSelectorSheet @@ -272,7 +272,7 @@ private fun HomeContentView( Spacer(modifier = Modifier.height(16.dp)) val activity = activityListViewModel ?: return@Column val latestActivities by activity.latestActivities.collectAsStateWithLifecycle() - HomeActivityList( + ActivityListSimple( items = latestActivities, onAllActivityClick = { walletNavController.navigate(HomeRoutes.AllActivity) }, onActivityItemClick = { rootNavController.navigateToActivityItem(it) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index a32b21cda..b08995de7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -33,7 +33,7 @@ import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.wallets.activity.ActivityListWithHeaders +import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -88,10 +88,10 @@ fun SavingsWalletScreen( ) } ) - Spacer(modifier = Modifier.height(16.dp)) + val activity = activityListViewModel ?: return@Column val onchainActivities by activity.onchainActivities.collectAsState() - ActivityListWithHeaders( + ActivityListGrouped( items = onchainActivities, showFooter = true, onAllActivityButtonClick = onAllActivityButtonClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 5e636fe58..f960c276b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -33,7 +33,7 @@ import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.wallets.activity.ActivityListWithHeaders +import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -100,12 +100,11 @@ fun SpendingWalletScreen( ) } ) - Spacer(modifier = Modifier.height(16.dp)) } val activity = activityListViewModel ?: return@Column val lightningActivities by activity.lightningActivities.collectAsState() - ActivityListWithHeaders( + ActivityListGrouped( items = lightningActivities, showFooter = true, onAllActivityButtonClick = onAllActivityButtonClick, 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 be576c714..4249a9fcf 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 @@ -38,6 +38,7 @@ import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.rawId import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime +import to.bitkit.ext.totalValue import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -50,7 +51,6 @@ import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.Title import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.CloseNavIcon -import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.wallets.activity.components.ActivityAddTagSheet import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon import to.bitkit.ui.shared.util.clickableAlpha @@ -89,7 +89,9 @@ fun ActivityDetailScreen( detailViewModel.setActivity(item) } - ScreenColumn { + Column( + modifier = Modifier.background(Colors.Black) + ) { AppTopBar( titleText = stringResource(item.getScreenTitleRes()), onBackClick = onBackClick, @@ -111,6 +113,7 @@ fun ActivityDetailScreen( ) if (showAddTagSheet) { ActivityAddTagSheet( + listViewModel = listViewModel, activityViewModel = detailViewModel, onDismiss = { showAddTagSheet = false }, ) @@ -142,13 +145,6 @@ private fun ActivityDetailContent( else -> item.v1.timestamp } } - val value = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> when { - isSent -> item.v1.value + item.v1.fee - else -> item.v1.value - } - } val paymentValue = when (item) { is Activity.Lightning -> item.v1.value is Activity.Onchain -> item.v1.value @@ -171,7 +167,7 @@ private fun ActivityDetailContent( .padding(vertical = 16.dp) ) { BalanceHeaderView( - sats = value.toLong(), + sats = item.totalValue().toLong(), prefix = amountPrefix, showBitcoinSymbol = false, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index e6fe414ac..e33139d6b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -34,6 +34,7 @@ import to.bitkit.R import to.bitkit.env.Env import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.rawId +import to.bitkit.ext.totalValue import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel @@ -129,17 +130,13 @@ private fun ActivityExploreContent( .fillMaxWidth() .padding(vertical = 16.dp) ) { - val value = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> item.v1.value - } val isSent = when (item) { is Activity.Lightning -> item.v1.txType == PaymentType.SENT is Activity.Onchain -> item.v1.txType == PaymentType.SENT } val amountPrefix = if (isSent) "-" else "+" BalanceHeaderView( - sats = value.toLong(), + sats = item.totalValue().toLong(), prefix = amountPrefix, showBitcoinSymbol = false, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt index c3db0073b..b9e94591e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/AllActivityScreen.kt @@ -1,50 +1,40 @@ package to.bitkit.ui.screens.wallets.activity +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BottomSheetType -import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.wallets.activity.components.ActivityListFilter -import to.bitkit.ui.screens.wallets.activity.components.ActivityRow -import to.bitkit.ui.screens.wallets.activity.components.EmptyActivityRow +import to.bitkit.ui.screens.wallets.activity.components.ActivityListGrouped +import to.bitkit.ui.screens.wallets.activity.components.ActivityTab +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.ActivityListViewModel import uniffi.bitkitcore.Activity -import uniffi.bitkitcore.LightningActivity -import uniffi.bitkitcore.OnchainActivity -import uniffi.bitkitcore.PaymentState -import uniffi.bitkitcore.PaymentType -import java.time.Instant -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import java.time.temporal.TemporalAdjusters -import java.time.temporal.WeekFields -import java.util.Calendar -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,322 +44,163 @@ fun AllActivityScreen( onActivityItemClick: (String) -> Unit, ) { val app = appViewModel ?: return + val filteredActivities by viewModel.filteredActivities.collectAsStateWithLifecycle() - ScreenColumn { - AppTopBar(stringResource(R.string.wallet__activity_all), onBackClick) + val searchText by viewModel.searchText.collectAsStateWithLifecycle() + val selectedTags by viewModel.selectedTags.collectAsStateWithLifecycle() + val startDate by viewModel.startDate.collectAsStateWithLifecycle() - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - ActivityListFilter( - viewModel = viewModel, - onTagClick = { app.showSheet(BottomSheetType.ActivityTagSelector) }, - onDateRangeClick = { app.showSheet(BottomSheetType.ActivityDateRangeSelector) }, - ) - Spacer(modifier = Modifier.height(16.dp)) - val filteredActivities by viewModel.filteredActivities.collectAsState() - ActivityListWithHeaders( - items = filteredActivities, - onActivityItemClick = onActivityItemClick, - onEmptyActivityRowClick = { app.showSheet(BottomSheetType.Receive) }, - ) - } - } -} + val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() + val tabs = ActivityTab.entries + val currentTabIndex = tabs.indexOf(selectedTab) -@Composable -fun ActivityListWithHeaders( - items: List?, - showFooter: Boolean = false, - onAllActivityButtonClick: () -> Unit = { }, - onActivityItemClick: (String) -> Unit, - onEmptyActivityRowClick: () -> Unit, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - if (items != null && items.isNotEmpty()) { - val groupedItems = groupActivityItems(items) - - LazyColumn( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - itemsIndexed(groupedItems) { index, item -> - when (item) { - is String -> { - Text( - text = item, - style = MaterialTheme.typography.titleSmall, - color = Colors.White64, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - is Activity -> { - ActivityRow(item, onActivityItemClick) - val hasNextItem = index < groupedItems.size - 1 && groupedItems[index + 1] !is String - if (hasNextItem) { - HorizontalDivider() - } - } - } - } - if (showFooter) { - item { - TertiaryButton( - text = stringResource(R.string.wallet__activity_show_all), - onClick = onAllActivityButtonClick, - modifier = Modifier - .wrapContentWidth() - .padding(top = 8.dp) - ) - } - } - item { - Spacer(modifier = Modifier.height(120.dp)) - } - } - } else { - EmptyActivityRow(onClick = onEmptyActivityRowClick) + DisposableEffect(Unit) { + onDispose { + viewModel.clearFilters() } } + + AllActivityScreenContent( + filteredActivities = filteredActivities, + searchText = searchText, + onSearchTextChange = { viewModel.setSearchText(it) }, + hasTagFilter = selectedTags.isNotEmpty(), + hasDateRangeFilter = startDate != null, + tabs = tabs, + currentTabIndex = currentTabIndex, + onTabChange = { viewModel.setTab(tabs[it]) }, + onBackClick = onBackClick, + onTagClick = { app.showSheet(BottomSheetType.ActivityTagSelector) }, + onDateRangeClick = { app.showSheet(BottomSheetType.ActivityDateRangeSelector) }, + onActivityItemClick = onActivityItemClick, + onEmptyActivityRowClick = { app.showSheet(BottomSheetType.Receive) }, + ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeActivityList( - items: List?, - onAllActivityClick: () -> Unit, +private fun AllActivityScreenContent( + filteredActivities: List?, + searchText: String, + onSearchTextChange: (String) -> Unit, + hasTagFilter: Boolean, + hasDateRangeFilter: Boolean, + tabs: List, + currentTabIndex: Int, + onTabChange: (Int) -> Unit, + onBackClick: () -> Unit, + onTagClick: () -> Unit, + onDateRangeClick: () -> Unit, onActivityItemClick: (String) -> Unit, onEmptyActivityRowClick: () -> Unit, ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.background(Colors.Black) ) { - if (items != null && items.isNotEmpty()) { - items.forEach { item -> - ActivityRow(item, onActivityItemClick) - HorizontalDivider() + Column( + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)) + .background( + Brush.horizontalGradient(listOf(Color(0xFF1e1e1e), Color(0xFF161616))) + ) + ) { + AppTopBar(stringResource(R.string.wallet__activity_all), onBackClick) + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + ActivityListFilter( + searchText = searchText, + onSearchTextChange = onSearchTextChange, + hasTagFilter = hasTagFilter, + hasDateRangeFilter = hasDateRangeFilter, + onTagClick = onTagClick, + onDateRangeClick = onDateRangeClick, + tabs = tabs, + currentTabIndex = currentTabIndex, + onTabChange = { onTabChange(tabs.indexOf(it)) }, + ) + Spacer(modifier = Modifier.height(16.dp)) } - TertiaryButton( - text = stringResource(R.string.wallet__activity_show_all), - onClick = onAllActivityClick, - modifier = Modifier - .wrapContentWidth() - .padding(top = 8.dp) - ) - } else { - EmptyActivityRow(onClick = onEmptyActivityRowClick) - } - } -} - -// region utils -private fun groupActivityItems(activityItems: List): List { - val now = Instant.now() - val zoneId = ZoneId.systemDefault() - val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) - - val startOfDay = today.toInstant().epochSecond - val startOfYesterday = today.minusDays(1).toInstant().epochSecond - val startOfWeek = today.with(TemporalAdjusters.previousOrSame(WeekFields.of(Locale.getDefault()).firstDayOfWeek)) - .toInstant().epochSecond - val startOfMonth = today.withDayOfMonth(1).toInstant().epochSecond - val startOfYear = today.withDayOfYear(1).toInstant().epochSecond - - val todayItems = mutableListOf() - val yesterdayItems = mutableListOf() - val weekItems = mutableListOf() - val monthItems = mutableListOf() - val yearItems = mutableListOf() - val earlierItems = mutableListOf() - - for (item in activityItems) { - val timestamp = when (item) { - is Activity.Lightning -> item.v1.timestamp.toLong() - is Activity.Onchain -> item.v1.timestamp.toLong() - } - when { - timestamp >= startOfDay -> todayItems.add(item) - timestamp >= startOfYesterday -> yesterdayItems.add(item) - timestamp >= startOfWeek -> weekItems.add(item) - timestamp >= startOfMonth -> monthItems.add(item) - timestamp >= startOfYear -> yearItems.add(item) - else -> earlierItems.add(item) - } - } - - return buildList { - if (todayItems.isNotEmpty()) { - add("TODAY") - addAll(todayItems) - } - if (yesterdayItems.isNotEmpty()) { - add("YESTERDAY") - addAll(yesterdayItems) - } - if (weekItems.isNotEmpty()) { - add("THIS WEEK") - addAll(weekItems) - } - if (monthItems.isNotEmpty()) { - add("THIS MONTH") - addAll(monthItems) - } - if (yearItems.isNotEmpty()) { - add("THIS YEAR") - addAll(yearItems) - } - if (earlierItems.isNotEmpty()) { - add("EARLIER") - addAll(earlierItems) } + ActivityListGrouped( + items = filteredActivities, + onActivityItemClick = onActivityItemClick, + onEmptyActivityRowClick = onEmptyActivityRowClick, + modifier = Modifier + .swipeToChangeTab( + currentTabIndex = currentTabIndex, + tabCount = tabs.size, + onTabChange = onTabChange, + ) + .padding(horizontal = 16.dp) + ) } } -// endregion -// region preview -@Preview -@Composable -private fun PreviewActivityListWithHeadersView() { - AppThemeSurface { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - ActivityListWithHeaders( - testActivityItems, - onAllActivityButtonClick = {}, - onActivityItemClick = {}, - onEmptyActivityRowClick = {}, - ) - } +private fun Modifier.swipeToChangeTab(currentTabIndex: Int, tabCount: Int, onTabChange: (Int) -> Unit) = composed { + val threshold = remember { 1500f } + val velocityTracker = remember { VelocityTracker() } + + pointerInput(currentTabIndex) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, _ -> + velocityTracker.addPosition(change.uptimeMillis, change.position) + }, + onDragEnd = { + val velocity = velocityTracker.calculateVelocity().x + when { + velocity >= threshold && currentTabIndex > 0 -> onTabChange(currentTabIndex - 1) + velocity <= -threshold && currentTabIndex < tabCount - 1 -> onTabChange(currentTabIndex + 1) + } + velocityTracker.resetTracking() + }, + onDragCancel = { + velocityTracker.resetTracking() + }, + ) } } -@Preview +@Preview(showSystemUi = true) @Composable -private fun PreviewActivityListItems() { +private fun Preview() { AppThemeSurface { - HomeActivityList( - testActivityItems, - onAllActivityClick = {}, + AllActivityScreenContent( + filteredActivities = previewActivityItems, + searchText = "", + onSearchTextChange = {}, + hasTagFilter = false, + hasDateRangeFilter = false, + tabs = ActivityTab.entries, + currentTabIndex = 0, + onTabChange = {}, + onBackClick = {}, + onTagClick = {}, + onDateRangeClick = {}, onActivityItemClick = {}, onEmptyActivityRowClick = {}, ) } } -@Preview +@Preview(showSystemUi = true) @Composable -private fun PreviewActivityListEmpty() { +private fun PreviewEmpty() { AppThemeSurface { - HomeActivityList( - items = emptyList(), - onAllActivityClick = {}, + AllActivityScreenContent( + filteredActivities = emptyList(), + searchText = "", + onSearchTextChange = {}, + hasTagFilter = false, + hasDateRangeFilter = false, + tabs = ActivityTab.entries, + currentTabIndex = 0, + onTabChange = {}, + onBackClick = {}, + onTagClick = {}, + onDateRangeClick = {}, onActivityItemClick = {}, onEmptyActivityRowClick = {}, ) } } - -private val today: Calendar = Calendar.getInstance() -private val yesterday: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -1) } -private val thisWeek: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -3) } -private val thisMonth: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -10) } -private val lastYear: Calendar = Calendar.getInstance().apply { add(Calendar.YEAR, -1) } - -val testActivityItems: List = listOf( - // Today - Activity.Onchain( - OnchainActivity( - id = "1", - txType = PaymentType.RECEIVED, - txId = "01", - value = 42_000_000_u, - fee = 200_u, - feeRate = 1_u, - address = "bc1", - confirmed = true, - timestamp = today.timeInMillis.toULong() / 1000u, - isBoosted = false, - isTransfer = true, - doesExist = true, - confirmTimestamp = today.timeInMillis.toULong() / 1000u, - channelId = "channelId", - transferTxId = "transferTxId", - createdAt = today.timeInMillis.toULong() / 1000u, - updatedAt = today.timeInMillis.toULong() / 1000u, - ) - ), - // Yesterday - Activity.Lightning( - LightningActivity( - id = "2", - txType = PaymentType.SENT, - status = PaymentState.PENDING, - value = 30_000_u, - fee = 15_u, - invoice = "lnbc2", - message = "Custom message", - timestamp = yesterday.timeInMillis.toULong() / 1000u, - preimage = "preimage1", - createdAt = yesterday.timeInMillis.toULong() / 1000u, - updatedAt = yesterday.timeInMillis.toULong() / 1000u, - ) - ), - // This Week - Activity.Lightning( - LightningActivity( - id = "3", - txType = PaymentType.RECEIVED, - status = PaymentState.FAILED, - value = 217_000_u, - fee = 17_u, - invoice = "lnbc3", - message = "", - timestamp = thisWeek.timeInMillis.toULong() / 1000u, - preimage = "preimage2", - createdAt = thisWeek.timeInMillis.toULong() / 1000u, - updatedAt = thisWeek.timeInMillis.toULong() / 1000u, - ) - ), - // This Month - Activity.Onchain( - OnchainActivity( - id = "4", - txType = PaymentType.RECEIVED, - txId = "04", - value = 950_000_u, - fee = 110_u, - feeRate = 1_u, - address = "bc1", - confirmed = false, - timestamp = thisMonth.timeInMillis.toULong() / 1000u, - isBoosted = false, - isTransfer = true, - doesExist = true, - confirmTimestamp = (today.timeInMillis + 3600_000).toULong() / 1000u, - channelId = "channelId", - transferTxId = "transferTxId", - createdAt = thisMonth.timeInMillis.toULong() / 1000u, - updatedAt = thisMonth.timeInMillis.toULong() / 1000u, - ) - ), - // Last Year - Activity.Lightning( - LightningActivity( - id = "5", - txType = PaymentType.SENT, - status = PaymentState.SUCCEEDED, - value = 200_000_u, - fee = 1_u, - invoice = "lnbc…", - message = "", - timestamp = (lastYear.timeInMillis.toULong() / 1000u), - preimage = null, - createdAt = (lastYear.timeInMillis.toULong() / 1000u), - updatedAt = (lastYear.timeInMillis.toULong() / 1000u), - ) - ), -) -// endregion diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 0c62016fb..46e7aeaef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -17,11 +17,15 @@ import androidx.compose.material3.DateRangePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import to.bitkit.R import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton @@ -33,10 +37,17 @@ import to.bitkit.ui.theme.Colors @OptIn(ExperimentalMaterial3Api::class) @Composable fun DateRangeSelectorSheet() { - val dateRangeState = rememberDateRangePickerState() val activity = activityListViewModel ?: return val app = appViewModel ?: return + val startDate by activity.startDate.collectAsState() + val endDate by activity.endDate.collectAsState() + + val dateRangeState = rememberDateRangePickerState( + initialSelectedStartDateMillis = startDate, + initialSelectedEndDateMillis = endDate, + ) + DateRangeSelectorSheetContent( dateRangeState = dateRangeState, onClearClick = { @@ -71,32 +82,33 @@ private fun DateRangeSelectorSheetContent( ) { DateRangePicker( state = dateRangeState, - modifier = Modifier.weight(1f), showModeToggle = false, colors = DatePickerDefaults.colors( containerColor = Color.Transparent, selectedDayContainerColor = Colors.Brand, dayInSelectionRangeContainerColor = Colors.Brand16, ), + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.height(32.dp)) + + Spacer(modifier = Modifier.height(16.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 16.dp) .fillMaxWidth(), ) { SecondaryButton( onClick = onClearClick, - text = "Clear", + text = stringResource(R.string.wallet__filter_clear), modifier = Modifier.weight(1f), ) PrimaryButton( onClick = onApplyClick, - text = "Apply", + text = stringResource(R.string.wallet__filter_apply), modifier = Modifier.weight(1f), ) } + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt index 636bdba81..755b97631 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/TagSelectorSheet.kt @@ -15,13 +15,13 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.activityListViewModel import to.bitkit.ui.appViewModel @@ -37,8 +37,8 @@ import to.bitkit.ui.theme.AppThemeSurface fun TagSelectorSheet() { val activity = activityListViewModel ?: return val app = appViewModel ?: return - val availableTags by activity.availableTags.collectAsState() - val selectedTags by activity.selectedTags.collectAsState() + val availableTags by activity.availableTags.collectAsStateWithLifecycle() + val selectedTags by activity.selectedTags.collectAsStateWithLifecycle() TagSelectorSheetContent( availableTags = availableTags, @@ -92,23 +92,24 @@ private fun TagSelectorSheetContent( } Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() ) { SecondaryButton( onClick = onClearClick, - text = "Clear", + text = stringResource(R.string.wallet__filter_clear), modifier = Modifier.weight(1f), ) PrimaryButton( onClick = onApplyClick, - text = "Apply", + text = stringResource(R.string.wallet__filter_apply), modifier = Modifier.weight(1f), ) } + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityAddTagSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityAddTagSheet.kt index 330263d6c..1ef7c34d3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityAddTagSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityAddTagSheet.kt @@ -20,11 +20,13 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.ActivityDetailViewModel +import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.TagsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActivityAddTagSheet( + listViewModel: ActivityListViewModel, activityViewModel: ActivityDetailViewModel, tagsViewModel: TagsViewModel = hiltViewModel(), onDismiss: () -> Unit, @@ -38,6 +40,7 @@ fun ActivityAddTagSheet( DisposableEffect(Unit) { onDispose { + listViewModel.updateAvailableTags() tagsViewModel.onInputUpdated("") } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt index 1241e8c1a..0c02c6690 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListFilter.kt @@ -1,93 +1,96 @@ package to.bitkit.ui.screens.wallets.activity.components import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CalendarToday -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R +import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppTextFieldDefaults +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.viewmodels.ActivityListViewModel @Composable fun ActivityListFilter( - viewModel: ActivityListViewModel, + searchText: String, + onSearchTextChange: (String) -> Unit, + hasTagFilter: Boolean, + hasDateRangeFilter: Boolean, onTagClick: () -> Unit, onDateRangeClick: () -> Unit, + tabs: List, + currentTabIndex: Int, + onTabChange: (ActivityTab) -> Unit, + modifier: Modifier = Modifier, ) { - val searchText by viewModel.searchText.collectAsState() - val selectedTags by viewModel.selectedTags.collectAsState() - val startDate by viewModel.startDate.collectAsState() + val focusManager = LocalFocusManager.current - Column { + Column(modifier = modifier) { Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .background(Colors.White10, RoundedCornerShape(32.dp)) + .background(Colors.White10, MaterialTheme.shapes.large) .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + .fillMaxWidth() ) { Icon( - imageVector = Icons.Default.Search, + painter = painterResource(R.drawable.ic_magnifying_glass), contentDescription = null, tint = if (searchText.isNotEmpty()) Colors.Brand else Colors.White64, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(8.dp)) TextField( value = searchText, - onValueChange = { viewModel.setSearchText(it) }, + onValueChange = onSearchTextChange, placeholder = { Text(text = stringResource(R.string.common__search)) }, - modifier = Modifier - .weight(1f) - .padding(0.dp), colors = AppTextFieldDefaults.transparent, + modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(12.dp)) Row { Icon( - imageVector = Icons.Default.Tag, + painter = painterResource(R.drawable.ic_tag), contentDescription = null, - tint = if (selectedTags.isNotEmpty()) Colors.Brand else Colors.White64, + tint = if (hasTagFilter) Colors.Brand else Colors.White64, modifier = Modifier - .clickable { + .size(24.dp) + .clickableAlpha { + focusManager.clearFocus() onTagClick() } ) Spacer(modifier = Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.CalendarToday, + painter = painterResource(R.drawable.ic_calendar), contentDescription = null, - tint = if (startDate != null) Colors.Brand else Colors.White64, + tint = if (hasDateRangeFilter) Colors.Brand else Colors.White64, modifier = Modifier - .clickable { + .size(24.dp) + .clickableAlpha { + focusManager.clearFocus() onDateRangeClick() } ) @@ -95,17 +98,15 @@ fun ActivityListFilter( } Spacer(modifier = Modifier.height(16.dp)) Column { - var selectedTab by remember { mutableStateOf(ActivityTab.ALL) } - - TabRow(selectedTabIndex = ActivityTab.entries.indexOf(selectedTab)) { - ActivityTab.entries.forEach { tab -> + TabRow( + selectedTabIndex = currentTabIndex, + containerColor = Color.Transparent, + ) { + tabs.map { tab -> Tab( - text = { Text(tab.title) }, - selected = selectedTab == tab, - onClick = { - selectedTab = tab - // TODO on tab change: update filtered activities - } + text = { Text(tab.uiText) }, + selected = tabs[currentTabIndex] == tab, + onClick = { onTabChange(tab) }, ) } } @@ -115,13 +116,31 @@ fun ActivityListFilter( enum class ActivityTab { ALL, SENT, RECEIVED, OTHER; + + val uiText: String + @Composable + get() = when (this) { + ALL -> stringResource(R.string.wallet__activity_tabs__all) + SENT -> stringResource(R.string.wallet__activity_tabs__sent) + RECEIVED -> stringResource(R.string.wallet__activity_tabs__received) + OTHER -> stringResource(R.string.wallet__activity_tabs__other) + } } -val ActivityTab.title: String - @Composable - get() = when (this) { - ActivityTab.ALL -> "All" - ActivityTab.SENT -> "Sent" - ActivityTab.RECEIVED -> "Received" - ActivityTab.OTHER -> "Other" +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + ActivityListFilter( + searchText = "", + onSearchTextChange = {}, + hasTagFilter = false, + onTagClick = {}, + hasDateRangeFilter = false, + onDateRangeClick = {}, + tabs = ActivityTab.entries, + currentTabIndex = 0, + onTabChange = {}, + ) } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt new file mode 100644 index 000000000..e8c7e5e37 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListGrouped.kt @@ -0,0 +1,212 @@ +package to.bitkit.ui.screens.wallets.activity.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.TertiaryButton +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import uniffi.bitkitcore.Activity +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields +import java.util.Locale + +@Composable +fun ActivityListGrouped( + items: List?, + onActivityItemClick: (String) -> Unit, + onEmptyActivityRowClick: () -> Unit, + modifier: Modifier = Modifier, + showFooter: Boolean = false, + onAllActivityButtonClick: () -> Unit = {}, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxSize() + ) { + if (items != null && items.isNotEmpty()) { + val groupedItems = groupActivityItems(items) + + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(top = 20.dp), + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed(groupedItems) { index, item -> + when (item) { + is String -> { + Caption13Up( + text = item, + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + is Activity -> { + ActivityRow(item, onActivityItemClick) + val hasNextItem = + index < groupedItems.size - 1 && groupedItems[index + 1] !is String + if (hasNextItem) { + HorizontalDivider() + } + } + } + } + if (showFooter) { + item { + TertiaryButton( + text = stringResource(R.string.wallet__activity_show_all), + onClick = onAllActivityButtonClick, + modifier = Modifier + .wrapContentWidth() + .padding(top = 8.dp) + ) + } + } + item { + Spacer(modifier = Modifier.height(120.dp)) + } + } + } else { + if (showFooter) { + // In Spending and Savings wallet + EmptyActivityRow(onClick = onEmptyActivityRowClick) + } else { + // On all activity screen when filtered list is empty + BodyM( + text = stringResource(R.string.wallet__activity_no), + color = Colors.White64, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } + } +} + +// region utils +private fun groupActivityItems(activityItems: List): List { + val now = Instant.now() + val zoneId = ZoneId.systemDefault() + val today = now.atZone(zoneId).truncatedTo(ChronoUnit.DAYS) + + val startOfDay = today.toInstant().epochSecond + val startOfYesterday = today.minusDays(1).toInstant().epochSecond + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(WeekFields.of(Locale.getDefault()).firstDayOfWeek)) + .toInstant().epochSecond + val startOfMonth = today.withDayOfMonth(1).toInstant().epochSecond + val startOfYear = today.withDayOfYear(1).toInstant().epochSecond + + val todayItems = mutableListOf() + val yesterdayItems = mutableListOf() + val weekItems = mutableListOf() + val monthItems = mutableListOf() + val yearItems = mutableListOf() + val earlierItems = mutableListOf() + + for (item in activityItems) { + val timestamp = when (item) { + is Activity.Lightning -> item.v1.timestamp.toLong() + is Activity.Onchain -> item.v1.timestamp.toLong() + } + when { + timestamp >= startOfDay -> todayItems.add(item) + timestamp >= startOfYesterday -> yesterdayItems.add(item) + timestamp >= startOfWeek -> weekItems.add(item) + timestamp >= startOfMonth -> monthItems.add(item) + timestamp >= startOfYear -> yearItems.add(item) + else -> earlierItems.add(item) + } + } + + return buildList { + if (todayItems.isNotEmpty()) { + add("TODAY") + addAll(todayItems) + } + if (yesterdayItems.isNotEmpty()) { + add("YESTERDAY") + addAll(yesterdayItems) + } + if (weekItems.isNotEmpty()) { + add("THIS WEEK") + addAll(weekItems) + } + if (monthItems.isNotEmpty()) { + add("THIS MONTH") + addAll(monthItems) + } + if (yearItems.isNotEmpty()) { + add("THIS YEAR") + addAll(yearItems) + } + if (earlierItems.isNotEmpty()) { + add("EARLIER") + addAll(earlierItems) + } + } +} +// endregion + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + ActivityListGrouped( + items = previewActivityItems, + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + AppThemeSurface { + ActivityListGrouped( + items = emptyList(), + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewEmptyWithFooter() { + AppThemeSurface { + ActivityListGrouped( + items = emptyList(), + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + showFooter = true, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt new file mode 100644 index 000000000..9e519bb20 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityListSimple.kt @@ -0,0 +1,74 @@ +package to.bitkit.ui.screens.wallets.activity.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.TertiaryButton +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems +import to.bitkit.ui.theme.AppThemeSurface +import uniffi.bitkitcore.Activity + +@Composable +fun ActivityListSimple( + items: List?, + onAllActivityClick: () -> Unit, + onActivityItemClick: (String) -> Unit, + onEmptyActivityRowClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + if (items != null && items.isNotEmpty()) { + items.forEach { item -> + ActivityRow(item, onActivityItemClick) + HorizontalDivider() + } + TertiaryButton( + text = stringResource(R.string.wallet__activity_show_all), + onClick = onAllActivityClick, + modifier = Modifier + .wrapContentWidth() + .padding(top = 8.dp) + ) + } else { + EmptyActivityRow(onClick = onEmptyActivityRowClick) + } + } +} + + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + ActivityListSimple( + items = previewActivityItems, + onAllActivityClick = {}, + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + AppThemeSurface { + ActivityListSimple( + items = emptyList(), + onAllActivityClick = {}, + onActivityItemClick = {}, + onEmptyActivityRowClick = {}, + ) + } +} 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 b96447c5d..99c730900 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -19,12 +20,14 @@ import to.bitkit.R import to.bitkit.ext.DatePattern import to.bitkit.ext.formatted import to.bitkit.ext.rawId +import to.bitkit.ext.totalValue import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB import to.bitkit.ui.currencyViewModel -import to.bitkit.ui.screens.wallets.activity.testActivityItems +import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems import to.bitkit.ui.shared.util.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -70,6 +73,7 @@ fun ActivityRow( Spacer(modifier = Modifier.width(16.dp)) Column( verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) ) { TransactionStatusText( txType = txType, @@ -91,9 +95,10 @@ fun ActivityRow( CaptionB( text = subtitleText, color = Colors.White64, + maxLines = 1, ) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(16.dp)) AmountView( item = item, prefix = amountPrefix, @@ -141,47 +146,62 @@ private fun AmountView( item: Activity, prefix: String, ) { + val amount = item.totalValue() + + val isPreview = LocalInspectionMode.current + if (isPreview) { + AmountViewContent( + title = amount.toLong().formatToModernDisplay(), + titlePrefix = prefix, + subtitle = "$ 123.45", + ) + return + } + val currency = currencyViewModel ?: return val (_, _, _, _, displayUnit, primaryDisplay) = LocalCurrencies.current - val amount = when (item) { - is Activity.Lightning -> item.v1.value - is Activity.Onchain -> when (item.v1.txType) { - PaymentType.SENT -> item.v1.value + item.v1.fee - else -> item.v1.value - } - } + currency.convert(sats = amount.toLong())?.let { converted -> val btcComponents = converted.bitcoinDisplay(displayUnit) - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(2.dp), + if (primaryDisplay == PrimaryDisplay.BITCOIN) { + AmountViewContent( + title = btcComponents.value, + titlePrefix = prefix, + subtitle = "${converted.symbol} ${converted.formatted}", + ) + } else { + AmountViewContent( + title = "${converted.symbol} ${converted.formatted}", + titlePrefix = prefix, + subtitle = btcComponents.value, + ) + } + } +} + +@Composable +private fun AmountViewContent( + title: String, + titlePrefix: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp), ) { - if (primaryDisplay == PrimaryDisplay.BITCOIN) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(1.dp), - ) { - BodyMSB(text = prefix, color = Colors.White64) - BodyMSB(text = btcComponents.value) - } - CaptionB( - text = "${converted.symbol} ${converted.formatted}", - color = Colors.White64, - ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(1.dp), - ) { - BodyMSB(text = prefix, color = Colors.White64) - BodyMSB(text = "${converted.symbol} ${converted.formatted}") - } - CaptionB( - text = btcComponents.value, - color = Colors.White64, - ) - } + BodyMSB(text = titlePrefix, color = Colors.White64) + BodyMSB(text = title) } + CaptionB( + text = subtitle, + color = Colors.White64, + ) } } @@ -200,7 +220,7 @@ private fun formattedTime(timestamp: ULong): String { } private class ActivityItemsPreviewProvider : PreviewParameterProvider { - override val values: Sequence get() = testActivityItems.asSequence() + override val values: Sequence get() = previewActivityItems.asSequence() } @Preview(showBackground = true) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt new file mode 100644 index 000000000..3b1e6a883 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/utils/PreviewItems.kt @@ -0,0 +1,125 @@ +package to.bitkit.ui.screens.wallets.activity.utils + +import uniffi.bitkitcore.Activity +import uniffi.bitkitcore.LightningActivity +import uniffi.bitkitcore.OnchainActivity +import uniffi.bitkitcore.PaymentState +import uniffi.bitkitcore.PaymentType +import java.util.Calendar + +val previewActivityItems = buildList { + val today: Calendar = Calendar.getInstance() + val yesterday: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -1) } + val thisWeek: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -3) } + val thisMonth: Calendar = Calendar.getInstance().apply { add(Calendar.DATE, -10) } + val lastYear: Calendar = Calendar.getInstance().apply { add(Calendar.YEAR, -1) } + + fun Calendar.epochSecond() = (timeInMillis / 1000).toULong() + + // Today + add( + Activity.Onchain( + OnchainActivity( + id = "1", + txType = PaymentType.RECEIVED, + txId = "01", + value = 42_000_000_u, + fee = 200_u, + feeRate = 1_u, + address = "bc1", + confirmed = true, + timestamp = today.epochSecond(), + isBoosted = false, + isTransfer = true, + doesExist = true, + confirmTimestamp = today.epochSecond(), + channelId = "channelId", + transferTxId = "transferTxId", + createdAt = today.epochSecond(), + updatedAt = today.epochSecond(), + ) + ) + ) + + // Yesterday + add( + Activity.Lightning( + LightningActivity( + id = "2", + txType = PaymentType.SENT, + status = PaymentState.PENDING, + value = 30_000_u, + fee = 15_u, + invoice = "lnbc2", + message = "Custom very long lightning activity message to test truncation", + timestamp = yesterday.epochSecond(), + preimage = "preimage1", + createdAt = yesterday.epochSecond(), + updatedAt = yesterday.epochSecond(), + ) + ) + ) + + // This Week + add( + Activity.Lightning( + LightningActivity( + id = "3", + txType = PaymentType.RECEIVED, + status = PaymentState.FAILED, + value = 217_000_u, + fee = 17_u, + invoice = "lnbc3", + message = "", + timestamp = thisWeek.epochSecond(), + preimage = "preimage2", + createdAt = thisWeek.epochSecond(), + updatedAt = thisWeek.epochSecond(), + ) + ) + ) + + // This Month + add( + Activity.Onchain( + OnchainActivity( + id = "4", + txType = PaymentType.RECEIVED, + txId = "04", + value = 950_000_u, + fee = 110_u, + feeRate = 1_u, + address = "bc1", + confirmed = false, + timestamp = thisMonth.epochSecond(), + isBoosted = false, + isTransfer = true, + doesExist = true, + confirmTimestamp = today.epochSecond() + 3600u, + channelId = "channelId", + transferTxId = "transferTxId", + createdAt = thisMonth.epochSecond(), + updatedAt = thisMonth.epochSecond(), + ) + ) + ) + + // Last Year + add( + Activity.Lightning( + LightningActivity( + id = "5", + txType = PaymentType.SENT, + status = PaymentState.SUCCEEDED, + value = 200_000_u, + fee = 1_u, + invoice = "lnbc…", + message = "", + timestamp = lastYear.epochSecond(), + preimage = null, + createdAt = lastYear.epochSecond(), + updatedAt = lastYear.epochSecond(), + ) + ) + ) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt index a2d60e6c0..9699a4c56 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt @@ -3,9 +3,11 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import to.bitkit.di.BgDispatcher import to.bitkit.ext.rawId import to.bitkit.services.CoreService import to.bitkit.utils.AddressChecker @@ -16,6 +18,7 @@ import javax.inject.Inject @HiltViewModel class ActivityDetailViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val addressChecker: AddressChecker, private val coreService: CoreService, ) : ViewModel() { @@ -34,7 +37,7 @@ class ActivityDetailViewModel @Inject constructor( fun loadTags() { val id = activity?.rawId() ?: return - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { try { val activityTags = coreService.activity.tags(forActivityId = id) _tags.value = activityTags @@ -47,7 +50,7 @@ class ActivityDetailViewModel @Inject constructor( fun removeTag(tag: String) { val id = activity?.rawId() ?: return - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { try { coreService.activity.dropTags(fromActivityId = id, tags = listOf(tag)) loadTags() @@ -59,7 +62,7 @@ class ActivityDetailViewModel @Inject constructor( fun addTag(tag: String) { val id = activity?.rawId() ?: return - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { try { val result = coreService.activity.appendTags(toActivityId = id, tags = listOf(tag)) if (result.isSuccess) { @@ -72,7 +75,7 @@ class ActivityDetailViewModel @Inject constructor( } fun fetchTransactionDetails(txid: String) { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { try { // TODO replace with bitkit-core method when available _txDetails.value = addressChecker.getTransaction(txid) diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt similarity index 68% rename from app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt rename to app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index 83498e487..97c9595b9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -3,22 +3,28 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.di.BgDispatcher import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus +import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger import uniffi.bitkitcore.Activity import uniffi.bitkitcore.ActivityFilter +import uniffi.bitkitcore.PaymentType import javax.inject.Inject @HiltViewModel class ActivityListViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningRepo: LightningRepo, private val ldkNodeEventBus: LdkNodeEventBus, @@ -43,7 +49,7 @@ class ActivityListViewModel @Inject constructor( val startDate = _startDate.asStateFlow() private val _endDate = MutableStateFlow(null) - // val endDate = _endDate.asStateFlow() + val endDate = _endDate.asStateFlow() private val _selectedTags = MutableStateFlow>(emptySet()) val selectedTags = _selectedTags.asStateFlow() @@ -62,8 +68,20 @@ class ActivityListViewModel @Inject constructor( private val _availableTags = MutableStateFlow>(emptyList()) val availableTags = _availableTags.asStateFlow() + private var isClearingFilters = false + + private val _selectedTab = MutableStateFlow(ActivityTab.ALL) + val selectedTab = _selectedTab.asStateFlow() + + fun setTab(tab: ActivityTab) { + _selectedTab.value = tab + viewModelScope.launch(bgDispatcher) { + updateFilteredActivities() + } + } + init { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { ldkNodeEventBus.events.collect { // TODO: sync only on specific events for better performance syncLdkNodePayments() @@ -79,34 +97,40 @@ class ActivityListViewModel @Inject constructor( @OptIn(FlowPreview::class) private fun observeSearchText() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { _searchText .debounce(300) .collect { - updateFilteredActivities() + if (!isClearingFilters) { + updateFilteredActivities() + } } } } private fun observeDateRange() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { combine(_startDate, _endDate) { _, _ -> } .collect { - updateFilteredActivities() + if (!isClearingFilters) { + updateFilteredActivities() + } } } } private fun observeSelectedTags() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { _selectedTags.collect { - updateFilteredActivities() + if (!isClearingFilters) { + updateFilteredActivities() + } } } } private fun syncState() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { try { // Fetch latest activities for the home screen val limitLatest = 3u @@ -125,22 +149,34 @@ class ActivityListViewModel @Inject constructor( } } - private suspend fun updateFilteredActivities() { + private suspend fun updateFilteredActivities() = withContext(bgDispatcher) { try { - _filteredActivities.value = coreService.activity.get( + var txType: PaymentType? = when (_selectedTab.value) { + ActivityTab.SENT -> PaymentType.SENT + ActivityTab.RECEIVED -> PaymentType.RECEIVED + else -> null + } + + val activities = coreService.activity.get( filter = ActivityFilter.ALL, - tags = if (_selectedTags.value.isEmpty()) null else _selectedTags.value.toList(), - search = if (_searchText.value.isEmpty()) null else _searchText.value, - minDate = _startDate.value?.toULong(), - maxDate = _endDate.value?.toULong(), + txType = txType, + tags = _selectedTags.value.takeIf { it.isNotEmpty() }?.toList(), + search = _searchText.value.takeIf { it.isNotEmpty() }, + minDate = _startDate.value?.let { it / 1000 }?.toULong(), + maxDate = _endDate.value?.let { it / 1000 }?.toULong(), ) + + _filteredActivities.value = when (_selectedTab.value) { + ActivityTab.OTHER -> activities.filter { it is Activity.Onchain && it.v1.isTransfer } + else -> activities + } } catch (e: Exception) { Logger.error("Failed to filter activities", e) } } - private fun updateAvailableTags() { - viewModelScope.launch { + fun updateAvailableTags() { + viewModelScope.launch(bgDispatcher) { try { _availableTags.value = coreService.activity.allPossibleTags() } catch (e: Exception) { @@ -164,6 +200,23 @@ class ActivityListViewModel @Inject constructor( _selectedTags.value = mutableSetOf() } + fun clearFilters() { + viewModelScope.launch(bgDispatcher) { + try { + isClearingFilters = true + + _searchText.value = "" + _selectedTags.value = emptySet() + _startDate.value = null + _endDate.value = null + + updateFilteredActivities() + } finally { + isClearingFilters = false + } + } + } + var isSyncingLdkNodePayments = false fun syncLdkNodePayments() { if (isSyncingLdkNodePayments) { @@ -171,7 +224,7 @@ class ActivityListViewModel @Inject constructor( return } - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { isSyncingLdkNodePayments = true lightningRepo.getPayments() .onSuccess { @@ -184,46 +237,15 @@ class ActivityListViewModel @Inject constructor( } } - fun addTags(activityId: String, tags: List) { - viewModelScope.launch { - try { - coreService.activity.appendTags(toActivityId = activityId, tags = tags) - syncState() - } catch (e: Exception) { - Logger.error("Failed to add tags to activity", e) - } - } - } - - fun removeTags(activityId: String, tags: List) { - viewModelScope.launch { - try { - coreService.activity.dropTags(fromActivityId = activityId, tags = tags) - syncState() - } catch (e: Exception) { - Logger.error("Failed to remove tags from activity", e) - } - } - } - - suspend fun getActivitiesWithTag(tag: String): List { - return try { - coreService.activity.get(tags = listOf(tag)) - } catch (e: Exception) { - Logger.error("Failed get activities by tag", e) - emptyList() - } - } - fun generateRandomTestData() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { coreService.activity.generateRandomTestData() syncState() } } fun removeAllActivities() { - viewModelScope.launch { + viewModelScope.launch(bgDispatcher) { coreService.activity.removeAll() syncState() } diff --git a/app/src/main/res/drawable/ic_magnifying_glass.xml b/app/src/main/res/drawable/ic_magnifying_glass.xml new file mode 100644 index 000000000..6dea006b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_magnifying_glass.xml @@ -0,0 +1,9 @@ + + + + + + + + +