From 0823f2e7b5548dd83531a3431f7e692553971910 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 17 May 2023 09:34:21 -0400 Subject: [PATCH] Additive rendering of statistics --- .../amethyst/ui/navigation/AppNavigation.kt | 18 +- .../amethyst/ui/note/UserReactionsRow.kt | 162 ++++++++++++++---- .../ui/screen/loggedIn/NotificationScreen.kt | 81 +++------ 3 files changed, 172 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 512e6c8e2..d1005265b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -18,6 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -37,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,7 +54,8 @@ fun AppNavigation( // Avoids creating ViewModels for performance reasons (up to 1 second delays) val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex } HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account @@ -67,6 +72,16 @@ fun AppNavigation( NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() + val userReactionsStatsModel: UserReactionsViewModel = viewModel() + val scope = rememberCoroutineScope() + + LaunchedEffect(accountHex) { + scope.launch(Dispatchers.IO) { + userReactionsStatsModel.load(account.userProfile()) + userReactionsStatsModel.initializeSuspend() + } + } + NavHost(navController, startDestination = Route.Home.route) { Route.Video.let { route -> composable(route.route, route.arguments, content = { @@ -135,6 +150,7 @@ fun AppNavigation( NotificationScreen( notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, accountViewModel = accountViewModel, navController = navController, scrollToTop = scrollToTop diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 5203edd1a..8eb5f573f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -25,8 +25,15 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController +import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer +import com.patrykandpatrick.vico.core.entry.composed.plus +import com.patrykandpatrick.vico.core.entry.entryOf import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent @@ -34,7 +41,6 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -63,7 +69,7 @@ fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountVie } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { - UserLikeReaction(model.replies[model.today]) + UserLikeReaction(model.reactions[model.today]) } Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { @@ -80,22 +86,21 @@ class UserReactionsViewModel : ViewModel() { var zaps by mutableStateOf>(emptyMap()) var replies by mutableStateOf>(emptyMap()) + var takenIntoAccount = setOf() + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() val today = sdf.format(LocalDateTime.now()) + var chartModel by mutableStateOf?>(null) + var axisLabels by mutableStateOf>(emptyList()) + fun load(baseUser: User) { user = baseUser reactions = emptyMap() boosts = emptyMap() zaps = emptyMap() replies = emptyMap() - } - - fun refresh() { - val scope = CoroutineScope(Job() + Dispatchers.Default) - scope.launch { - refreshSuspended() - } + takenIntoAccount = emptySet() } fun formatDate(createAt: Long): String { @@ -106,46 +111,133 @@ class UserReactionsViewModel : ViewModel() { ) } - fun refreshSuspended() { + fun initializeSuspend() { val currentUser = user?.pubkeyHex ?: return val reactions = mutableMapOf() val boosts = mutableMapOf() val zaps = mutableMapOf() val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() LocalCache.notes.values.forEach { val noteEvent = it.event - if (noteEvent is ReactionEvent) { - if (noteEvent.isTaggedUser(currentUser)) { - val netDate = formatDate(noteEvent.createdAt) - reactions[netDate] = (reactions[netDate] ?: 0) + 1 - } - } - if (noteEvent is RepostEvent) { - if (noteEvent.isTaggedUser(currentUser)) { - val netDate = formatDate(noteEvent.createdAt) - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - } - } - if (noteEvent is LnZapEvent) { - if (noteEvent.isTaggedUser(currentUser)) { - val netDate = formatDate(noteEvent.createdAt) - zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) - } - } - if (noteEvent is TextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser)) { - val netDate = formatDate(noteEvent.createdAt) - replies[netDate] = (replies[netDate] ?: 0) + 1 + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } } } } + this.takenIntoAccount = takenIntoAccount this.reactions = reactions this.replies = replies this.zaps = zaps this.boosts = boosts + + refreshChartModel() + } + + fun addToStatsSuspend(newNotes: Set) { + val currentUser = user?.pubkeyHex ?: return + + val reactions = this.reactions.toMutableMap() + val boosts = this.boosts.toMutableMap() + val zaps = this.zaps.toMutableMap() + val replies = this.replies.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + + refreshChartModel() + } + } + + private fun refreshChartModel() { + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") + + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } + + val listOfCountCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) } + ) + + val listOfValueCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) } + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } + this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) } var collectorJob: Job? = null @@ -153,7 +245,7 @@ class UserReactionsViewModel : ViewModel() { init { collectorJob = viewModelScope.launch(Dispatchers.IO) { LocalCache.live.newEventBundles.collect { newNotes -> - refresh() + addToStatsSuspend(newNotes) } } } @@ -191,7 +283,7 @@ fun UserBoostReaction( Icon( painter = painterResource(R.drawable.ic_retweeted), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(24.dp), tint = Color.Unspecified ) @@ -231,7 +323,7 @@ fun UserZapReaction( Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(24.dp), tint = BitcoinOrange ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index aab31e69e..298955136 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -35,34 +34,32 @@ import com.patrykandpatrick.vico.compose.style.ProvideChartStyle import com.patrykandpatrick.vico.core.DefaultAlpha import com.patrykandpatrick.vico.core.axis.AxisPosition import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter -import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel import com.patrykandpatrick.vico.core.chart.composed.plus import com.patrykandpatrick.vico.core.chart.line.LineChart import com.patrykandpatrick.vico.core.chart.values.ChartValues import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders -import com.patrykandpatrick.vico.core.entry.ChartEntryModel -import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer import com.patrykandpatrick.vico.core.entry.composed.plus -import com.patrykandpatrick.vico.core.entry.entryOf import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.OneGiga +import com.vitorpamplona.amethyst.ui.note.OneKilo +import com.vitorpamplona.amethyst.ui.note.OneMega import com.vitorpamplona.amethyst.ui.note.UserReactionsRow import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel -import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange -import kotlinx.coroutines.launch -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter +import java.math.BigDecimal +import java.math.RoundingMode import kotlin.math.roundToInt @Composable fun NotificationScreen( notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, scrollToTop: Boolean = false @@ -100,7 +97,7 @@ fun NotificationScreen( Column( modifier = Modifier.padding(vertical = 0.dp) ) { - SummaryBar(accountViewModel, navController) + SummaryBar(userReactionsStatsModel, accountViewModel, navController) CardFeedView( viewModel = notifFeedViewModel, accountViewModel = accountViewModel, @@ -114,49 +111,11 @@ fun NotificationScreen( } @Composable -fun SummaryBar(accountViewModel: AccountViewModel, navController: NavController) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val accountUser = remember(accountState) { accountState?.account?.userProfile() } ?: return - - val model: UserReactionsViewModel = viewModel() - - var chartModel by remember(accountState) { mutableStateOf?>(null) } - var axisLabels by remember(accountState) { mutableStateOf>(emptyList()) } - - val scope = rememberCoroutineScope() - +fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController) { var showChart by remember { mutableStateOf(false) } - LaunchedEffect(accountUser.pubkeyHex) { - scope.launch { - model.load(accountUser) - model.refreshSuspended() - val day = 24 * 60 * 60L - val now = LocalDateTime.now() - val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") - - val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { model.sdf.format(now.minusSeconds(day * it)) } - axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } - - val listOfCountCurves = listOf( - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.replies[dateStr]?.toFloat() ?: 0f) }, - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.boosts[dateStr]?.toFloat() ?: 0f) }, - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.reactions[dateStr]?.toFloat() ?: 0f) } - ) - - val listOfValueCurves = listOf( - dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.zaps[dateStr]?.toFloat() ?: 0f) } - ) - - val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() - val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() - - chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) - } - } - UserReactionsRow(model, accountViewModel, navController) { showChart = !showChart } @@ -197,9 +156,13 @@ fun SummaryBar(accountViewModel: AccountViewModel, navController: NavController) targetVerticalAxisPosition = AxisPosition.Vertical.End ) - chartModel?.let { + model.chartModel?.let { if (showChart) { - Row(modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp).clickable(onClick = { showChart = !showChart })) { + Row( + modifier = Modifier + .padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }) + ) { ProvideChartStyle() { Chart( chart = remember(lineChartCount, lineChartZaps) { @@ -213,7 +176,7 @@ fun SummaryBar(accountViewModel: AccountViewModel, navController: NavController) valueFormatter = AmountAxisValueFormatter() ), bottomAxis = bottomAxis( - valueFormatter = LabelValueFormatter(axisLabels) + valueFormatter = LabelValueFormatter(model.axisLabels) ) ) } @@ -249,6 +212,18 @@ class AmountAxisValueFormatter() : AxisValueFormatter value: Float, chartValues: ChartValues ): String { - return showAmount(value.toBigDecimal()) + return showAmountAxis(value.toBigDecimal()) + } +} + +fun showAmountAxis(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> "%.0fG".format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> "%.0fM".format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneKilo -> "%.0fk".format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> "%.0f".format(amount) } }