From c64d179f7f3c687fd5ccee3e274ca2a81b3f1dfa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 13 Aug 2024 18:56:53 -0400 Subject: [PATCH] Migrates Notification Summary to the new state model --- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/note/UserReactionsRow.kt | 331 +----------------- .../loggedIn/AccountFeedContentStates.kt | 8 + .../ui/screen/loggedIn/AccountViewModel.kt | 27 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 10 - .../notifications/NotificationScreen.kt | 189 +--------- .../notifications/NotificationSummaryState.kt | 303 ++++++++++++++++ .../notifications/NotificationSummaryView.kt | 210 +++++++++++ 8 files changed, 550 insertions(+), 532 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index dfb593637..b77a37f39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -41,7 +41,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen @@ -71,7 +70,6 @@ import java.net.URLDecoder @Composable fun AppNavigation( - userReactionsStatsModel: UserReactionsViewModel, navController: NavHostController, accountViewModel: AccountViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, @@ -186,7 +184,7 @@ fun AppNavigation( content = { NotificationScreen( notifFeedContentState = accountViewModel.feedStates.notifications, - userReactionsStatsModel = userReactionsStatsModel, + notifSummaryState = accountViewModel.feedStates.notificationSummary, sharedPreferencesViewModel = sharedPreferencesViewModel, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index bce22a8f7..e003635bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note -import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +32,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -42,22 +40,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -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.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.RoyalBlue @@ -65,29 +50,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.events.BaseTextNoteEvent -import com.vitorpamplona.quartz.events.GenericRepostEvent -import com.vitorpamplona.quartz.events.LnZapEvent -import com.vitorpamplona.quartz.events.ReactionEvent -import com.vitorpamplona.quartz.events.RepostEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun UserReactionsRow( - model: UserReactionsViewModel, + model: NotificationSummaryState, onClick: () -> Unit, ) { Row( @@ -130,7 +96,7 @@ fun UserReactionsRow( } @Composable -private fun UserZapModel(model: UserReactionsViewModel) { +private fun UserZapModel(model: NotificationSummaryState) { Icon( imageVector = Icons.Default.Bolt, contentDescription = stringRes(R.string.zaps), @@ -144,7 +110,7 @@ private fun UserZapModel(model: UserReactionsViewModel) { } @Composable -private fun UserReactionModel(model: UserReactionsViewModel) { +private fun UserReactionModel(model: NotificationSummaryState) { LikedIcon(modifier = Size20Modifier) Spacer(modifier = StdHorzSpacer) @@ -153,7 +119,7 @@ private fun UserReactionModel(model: UserReactionsViewModel) { } @Composable -private fun UserBoostModel(model: UserReactionsViewModel) { +private fun UserBoostModel(model: NotificationSummaryState) { RepostedIcon( modifier = Size24Modifier, tint = Color.Unspecified, @@ -165,7 +131,7 @@ private fun UserBoostModel(model: UserReactionsViewModel) { } @Composable -private fun UserReplyModel(model: UserReactionsViewModel) { +private fun UserReplyModel(model: NotificationSummaryState) { CommentIcon(Size20Modifier, RoyalBlue) Spacer(modifier = StdHorzSpacer) @@ -173,285 +139,8 @@ private fun UserReplyModel(model: UserReactionsViewModel) { UserReplyReaction(model) } -@Stable -class UserReactionsViewModel( - val account: Account, -) : ViewModel() { - val user: User = account.userProfile() - - private var _reactions = MutableStateFlow>(emptyMap()) - private var _boosts = MutableStateFlow>(emptyMap()) - private var _zaps = MutableStateFlow>(emptyMap()) - private var _replies = MutableStateFlow>(emptyMap()) - - private var _chartModel = MutableStateFlow?>(null) - private var _axisLabels = MutableStateFlow>(emptyList()) - - val reactions = _reactions.asStateFlow() - val boosts = _boosts.asStateFlow() - val zaps = _zaps.asStateFlow() - val replies = _replies.asStateFlow() - - val chartModel = _chartModel.asStateFlow() - val axisLabels = _axisLabels.asStateFlow() - - private var takenIntoAccount = setOf() - private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() - - val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() - val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() - - var shouldShowDecimalsInAxis = false - - fun formatDate(createAt: Long): String = - sdf.format( - Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), - ) - - fun today() = sdf.format(LocalDateTime.now()) - - private suspend fun initializeSuspend() { - checkNotInMainThread() - - val currentUser = user.pubkeyHex - - val reactions = mutableMapOf() - val boosts = mutableMapOf() - val zaps = mutableMapOf() - val replies = mutableMapOf() - val takenIntoAccount = mutableSetOf() - - LocalCache.notes.forEach { _, it -> - 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()) - } - } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { - 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) - ) { // the user might be sending his own receipts 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 BaseTextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val isCitation = - noteEvent.findCitations().any { - LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser - } - - val netDate = formatDate(noteEvent.createdAt) - if (isCitation) { - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - } else { - replies[netDate] = (replies[netDate] ?: 0) + 1 - } - takenIntoAccount.add(noteEvent.id()) - } - } - } - } - - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - - suspend fun addToStatsSuspend(newBlockNotes: Set>) { - checkNotInMainThread() - - val currentUser = user.pubkeyHex - - val reactions = this._reactions.value.toMutableMap() - val boosts = this._boosts.value.toMutableMap() - val zaps = this._zaps.value.toMutableMap() - val replies = this._replies.value.toMutableMap() - val takenIntoAccount = this.takenIntoAccount.toMutableSet() - var hasNewElements = false - - newBlockNotes.forEach { newNotes -> - 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 || noteEvent is GenericRepostEvent) { - 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 User might be sending his own receipts - 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 BaseTextNoteEvent) { - if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { - val isCitation = - noteEvent.findCitations().any { - LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser - } - - val netDate = formatDate(noteEvent.createdAt) - if (isCitation) { - boosts[netDate] = (boosts[netDate] ?: 0) + 1 - } else { - replies[netDate] = (replies[netDate] ?: 0) + 1 - } - takenIntoAccount.add(noteEvent.id()) - hasNewElements = true - } - } - } - } - } - - if (hasNewElements) { - this.takenIntoAccount = takenIntoAccount - this._reactions.emit(reactions) - this._replies.emit(replies) - this._zaps.emit(zaps) - this._boosts.emit(boosts) - - refreshChartModel() - } - } - - private suspend fun refreshChartModel() { - checkNotInMainThread() - - 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.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) - }, - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val listOfValueCurves = - listOf( - dataAxisLabels.mapIndexed { index, dateStr -> - entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) - }, - ) - - val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() - val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() - - chartEntryModelProducer1?.let { chart1 -> - chartEntryModelProducer2?.let { chart2 -> - this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) - - this._axisLabels.emit( - listOf(6, 5, 4, 3, 2, 1, 0).map { - displayAxisFormatter.format(now.minusSeconds(day * it)) - }, - ) - this._chartModel.emit(chart1.plus(chart2)) - } - } - } - - // determine if the min max are so close that they render to the same number. - fun shouldShowDecimals( - min: Float, - max: Float, - ): Boolean { - val step = (max - min) / 8 - - var previous = showAmountAxis(min.toBigDecimal()) - for (i in 1..7) { - val current = showAmountAxis((min + (i * step)).toBigDecimal()) - if (previous == current) { - return true - } - previous = current - } - - return false - } - - var collectorJob: Job? = null - - init { - Log.d("Init", "User Reactions Row") - viewModelScope.launch(Dispatchers.IO) { - initializeSuspend() - - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - checkNotInMainThread() - - invalidateInsertData(newNotes) - } - } - } - } - - private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) - - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) } - } - - override fun onCleared() { - collectorJob?.cancel() - bundlerInsert.cancel() - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - super.onCleared() - } - - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): UserReactionsViewModel = UserReactionsViewModel(account) as UserReactionsViewModel - } -} - @Composable -fun UserReplyReaction(model: UserReactionsViewModel) { +fun UserReplyReaction(model: NotificationSummaryState) { val showCounts by model.todaysReplyCount.collectAsStateWithLifecycle("") Text( @@ -462,7 +151,7 @@ fun UserReplyReaction(model: UserReactionsViewModel) { } @Composable -fun UserBoostReaction(model: UserReactionsViewModel) { +fun UserBoostReaction(model: NotificationSummaryState) { val boosts by model.todaysBoostCount.collectAsStateWithLifecycle("") Text( @@ -473,7 +162,7 @@ fun UserBoostReaction(model: UserReactionsViewModel) { } @Composable -fun UserLikeReaction(model: UserReactionsViewModel) { +fun UserLikeReaction(model: NotificationSummaryState) { val reactions by model.todaysReactionCount.collectAsStateWithLifecycle("") Text( @@ -484,7 +173,7 @@ fun UserLikeReaction(model: UserReactionsViewModel) { } @Composable -fun UserZapReaction(model: UserReactionsViewModel) { +fun UserZapReaction(model: NotificationSummaryState) { val amount by model.todaysZapAmount.collectAsStateWithLifecycle("") Text( amount, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 31a36dbc6..71fa413be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState class AccountFeedContentStates( val accountViewModel: AccountViewModel, @@ -54,6 +55,11 @@ class AccountFeedContentStates( val discoverPublicChats = FeedContentState(DiscoverChatFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) val notifications = CardFeedContentState(NotificationFeedFilter(accountViewModel.account), accountViewModel.viewModelScope) + val notificationSummary = NotificationSummaryState(accountViewModel.account) + + suspend fun init() { + notificationSummary.initializeSuspend() + } fun updateFeedsWith(newNotes: Set) { homeNewThreads.updateFeedWith(newNotes) @@ -71,6 +77,7 @@ class AccountFeedContentStates( discoverPublicChats.updateFeedWith(newNotes) notifications.updateFeedWith(newNotes) + notificationSummary.invalidateInsertData(newNotes) } fun destroy() { @@ -89,5 +96,6 @@ class AccountFeedContentStates( discoverPublicChats.destroy() notifications.destroy() + notificationSummary.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 429f988e7..aa51112ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1221,18 +1221,23 @@ class AccountViewModel( init { Log.d("Init", "AccountViewModel") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - Log.d( - "Rendering Metrics", - "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", - ) - feedStates.updateFeedsWith(newNotes) - invalidateInsertData(newNotes) - upgradeAttestations() + viewModelScope.launch(Dispatchers.Default) { + feedStates.init() + // awaits for init to finish before starting to capture new events. + + collectorJob = + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + Log.d( + "Rendering Metrics", + "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", + ) + feedStates.updateFeedsWith(newNotes) + invalidateInsertData(newNotes) + upgradeAttestations() + } } - } + } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 1c3afedc6..100f51678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -95,7 +95,6 @@ import com.vitorpamplona.amethyst.ui.navigation.FollowListViewModel import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route.Companion.InvertedLayouts import com.vitorpamplona.amethyst.ui.navigation.getRouteWithArguments -import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel @@ -173,12 +172,6 @@ fun MainScreen( factory = FollowListViewModel.Factory(accountViewModel.account), ) - val userReactionsStatsModel: UserReactionsViewModel = - viewModel( - key = "UserReactionsViewModel", - factory = UserReactionsViewModel.Factory(accountViewModel.account), - ) - val navBottomRow = remember(navController, accountViewModel) { { route: Route, selected: Boolean -> @@ -235,7 +228,6 @@ fun MainScreen( navPopBack = navPopBack, openDrawer = { scope.launch { drawerState.open() } }, accountStateViewModel = accountStateViewModel, - userReactionsStatsModel = userReactionsStatsModel, followListsViewModel = followListsViewModel, sharedPreferencesViewModel = sharedPreferencesViewModel, accountViewModel = accountViewModel, @@ -273,7 +265,6 @@ private fun MainScaffold( navPopBack: () -> Unit, openDrawer: () -> Unit, accountStateViewModel: AccountStateViewModel, - userReactionsStatsModel: UserReactionsViewModel, followListsViewModel: FollowListViewModel, sharedPreferencesViewModel: SharedPreferencesViewModel, accountViewModel: AccountViewModel, @@ -398,7 +389,6 @@ private fun MainScaffold( .imePadding(), ) { AppNavigation( - userReactionsStatsModel = userReactionsStatsModel, navController = navController, accountViewModel = accountViewModel, sharedPreferencesViewModel = sharedPreferencesViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index d9e059d85..e98b87edb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -22,28 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import android.Manifest import android.os.Build -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -51,48 +38,18 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.patrykandpatrick.vico.compose.axis.axisLabelComponent -import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis -import com.patrykandpatrick.vico.compose.axis.vertical.rememberEndAxis -import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis -import com.patrykandpatrick.vico.compose.chart.Chart -import com.patrykandpatrick.vico.compose.chart.line.lineChart -import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush -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.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.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys 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.TenKilo -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.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.RoyalBlue -import com.vitorpamplona.amethyst.ui.theme.chartStyle -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import kotlin.math.roundToInt @Composable fun NotificationScreen( notifFeedContentState: CardFeedContentState, - userReactionsStatsModel: UserReactionsViewModel, + notifSummaryState: NotificationSummaryState, sharedPreferencesViewModel: SharedPreferencesViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -117,7 +74,7 @@ fun NotificationScreen( Column(Modifier.fillMaxHeight()) { SummaryBar( - model = userReactionsStatsModel, + state = notifSummaryState, ) HorizontalDivider( thickness = DividerThickness, @@ -170,145 +127,3 @@ fun WatchAccountForNotifications( notifFeedContentState.checkKeysInvalidateDataAndSendToTop() } } - -@Composable -fun SummaryBar(model: UserReactionsViewModel) { - var showChart by remember { mutableStateOf(false) } - - UserReactionsRow(model) { showChart = !showChart } - - if (showChart) { - val lineChartCount = - lineChart( - lines = - listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.Start, - ) - - val lineChartZaps = - lineChart( - lines = - listOf(BitcoinOrange).map { lineChartColor -> - LineChart.LineSpec( - lineColor = lineChartColor.toArgb(), - lineBackgroundShader = - DynamicShaders.fromBrush( - Brush.verticalGradient( - listOf( - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), - lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), - ), - ), - ), - ) - }, - targetVerticalAxisPosition = AxisPosition.Vertical.End, - ) - - Row( - modifier = - Modifier - .padding(vertical = 10.dp, horizontal = 20.dp) - .clickable(onClick = { showChart = !showChart }), - ) { - ProvideChartStyle( - chartStyle = MaterialTheme.colorScheme.chartStyle, - ) { - ObserveAndShowChart(model, lineChartCount, lineChartZaps) - } - } - } -} - -@Composable -private fun ObserveAndShowChart( - model: UserReactionsViewModel, - lineChartCount: LineChart, - lineChartZaps: LineChart, -) { - val axisModel = model.axisLabels.collectAsStateWithLifecycle() - val chartModel by model.chartModel.collectAsStateWithLifecycle() - - chartModel?.let { - Chart( - chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, - model = it, - startAxis = - rememberStartAxis( - valueFormatter = CountAxisValueFormatter(), - ), - endAxis = - rememberEndAxis( - label = axisLabelComponent(color = BitcoinOrange), - valueFormatter = AmountAxisValueFormatter(model.shouldShowDecimalsInAxis), - ), - bottomAxis = - rememberBottomAxis( - valueFormatter = LabelValueFormatter(axisModel), - ), - ) - } -} - -@Stable -class LabelValueFormatter( - val axisLabels: State>, -) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = axisLabels.value[value.roundToInt()] -} - -@Stable -class CountAxisValueFormatter : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = showCount(value.roundToInt()) -} - -@Stable -class AmountAxisValueFormatter( - val showDecimals: Boolean, -) : AxisValueFormatter { - override fun formatValue( - value: Float, - chartValues: ChartValues, - ): String = - if (showDecimals) { - showAmount(value.toBigDecimal()) - } else { - showAmountAxis(value.toBigDecimal()) - } -} - -var dfG: DecimalFormat = DecimalFormat("#G") -var dfM: DecimalFormat = DecimalFormat("#M") -var dfK: DecimalFormat = DecimalFormat("#k") -var dfN: DecimalFormat = DecimalFormat("#") - -fun showAmountAxis(amount: BigDecimal?): String { - if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" - - return when { - amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.format(amount) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt new file mode 100644 index 000000000..df5c81cce --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -0,0 +1,303 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import android.util.Log +import androidx.compose.runtime.Stable +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.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.note.showCount +import com.vitorpamplona.ammolite.relays.BundledInsert +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.events.BaseTextNoteEvent +import com.vitorpamplona.quartz.events.GenericRepostEvent +import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.ReactionEvent +import com.vitorpamplona.quartz.events.RepostEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Stable +class NotificationSummaryState( + val account: Account, +) { + val user: User = account.userProfile() + + private var _reactions = MutableStateFlow>(emptyMap()) + private var _boosts = MutableStateFlow>(emptyMap()) + private var _zaps = MutableStateFlow>(emptyMap()) + private var _replies = MutableStateFlow>(emptyMap()) + + private var _chartModel = MutableStateFlow?>(null) + private var _axisLabels = MutableStateFlow>(emptyList()) + + val reactions = _reactions.asStateFlow() + val boosts = _boosts.asStateFlow() + val zaps = _zaps.asStateFlow() + val replies = _replies.asStateFlow() + + val chartModel = _chartModel.asStateFlow() + val axisLabels = _axisLabels.asStateFlow() + + private var takenIntoAccount = setOf() + private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + + val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged() + val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged() + + var shouldShowDecimalsInAxis = false + + fun formatDate(createAt: Long): String = + sdf.format( + Instant.ofEpochSecond(createAt).atZone(ZoneId.systemDefault()).toLocalDateTime(), + ) + + fun today() = sdf.format(LocalDateTime.now()) + + public suspend fun initializeSuspend() { + checkNotInMainThread() + + val currentUser = user.pubkeyHex + + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() + + LocalCache.notes.forEach { _, it -> + 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()) + } + } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { + 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) + ) { // the user might be sending his own receipts 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 BaseTextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val isCitation = + noteEvent.findCitations().any { + LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser + } + + val netDate = formatDate(noteEvent.createdAt) + if (isCitation) { + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + } else { + replies[netDate] = (replies[netDate] ?: 0) + 1 + } + takenIntoAccount.add(noteEvent.id()) + } + } + } + } + + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() + } + + suspend fun addToStatsSuspend(newBlockNotes: Set>) { + checkNotInMainThread() + + val currentUser = user.pubkeyHex + + val reactions = this._reactions.value.toMutableMap() + val boosts = this._boosts.value.toMutableMap() + val zaps = this._zaps.value.toMutableMap() + val replies = this._replies.value.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newBlockNotes.forEach { newNotes -> + 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 || noteEvent is GenericRepostEvent) { + 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 User might be sending his own receipts + 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 BaseTextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val isCitation = + noteEvent.findCitations().any { + LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == currentUser + } + + val netDate = formatDate(noteEvent.createdAt) + if (isCitation) { + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + } else { + replies[netDate] = (replies[netDate] ?: 0) + 1 + } + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this._reactions.emit(reactions) + this._replies.emit(replies) + this._zaps.emit(zaps) + this._boosts.emit(boosts) + + refreshChartModel() + } + } + + private suspend fun refreshChartModel() { + checkNotInMainThread() + + 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.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) + }, + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val listOfValueCurves = + listOf( + dataAxisLabels.mapIndexed { index, dateStr -> + entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) + }, + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + chartEntryModelProducer1?.let { chart1 -> + chartEntryModelProducer2?.let { chart2 -> + this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY) + + this._axisLabels.emit( + listOf(6, 5, 4, 3, 2, 1, 0).map { + displayAxisFormatter.format(now.minusSeconds(day * it)) + }, + ) + this._chartModel.emit(chart1.plus(chart2)) + } + } + } + + // determine if the min max are so close that they render to the same number. + fun shouldShowDecimals( + min: Float, + max: Float, + ): Boolean { + val step = (max - min) / 8 + + var previous = showAmountAxis(min.toBigDecimal()) + for (i in 1..7) { + val current = showAmountAxis((min + (i * step)).toBigDecimal()) + if (previous == current) { + return true + } + previous = current + } + + return false + } + + private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) + + fun invalidateInsertData(newItems: Set) { + bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) } + } + + fun destroy() { + bundlerInsert.cancel() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt new file mode 100644 index 000000000..ef7b191eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.patrykandpatrick.vico.compose.axis.axisLabelComponent +import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.rememberEndAxis +import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis +import com.patrykandpatrick.vico.compose.chart.Chart +import com.patrykandpatrick.vico.compose.chart.line.lineChart +import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush +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.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.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.TenKilo +import com.vitorpamplona.amethyst.ui.note.UserReactionsRow +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.note.showCount +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.RoyalBlue +import com.vitorpamplona.amethyst.ui.theme.chartStyle +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.math.roundToInt + +@Composable +fun SummaryBar(state: NotificationSummaryState) { + var showChart by remember { mutableStateOf(false) } + + UserReactionsRow(state) { showChart = !showChart } + + if (showChart) { + val lineChartCount = + lineChart( + lines = + listOf(RoyalBlue, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start, + ) + + val lineChartZaps = + lineChart( + lines = + listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = + DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END), + ), + ), + ), + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End, + ) + + Row( + modifier = + Modifier + .padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }), + ) { + ProvideChartStyle( + chartStyle = MaterialTheme.colorScheme.chartStyle, + ) { + ObserveAndShowChart(state, lineChartCount, lineChartZaps) + } + } + } +} + +@Composable +private fun ObserveAndShowChart( + state: NotificationSummaryState, + lineChartCount: LineChart, + lineChartZaps: LineChart, +) { + val axisModel = state.axisLabels.collectAsStateWithLifecycle() + val chartModel by state.chartModel.collectAsStateWithLifecycle() + + chartModel?.let { + Chart( + chart = remember(lineChartCount, lineChartZaps) { lineChartCount.plus(lineChartZaps) }, + model = it, + startAxis = + rememberStartAxis( + valueFormatter = CountAxisValueFormatter(), + ), + endAxis = + rememberEndAxis( + label = axisLabelComponent(color = BitcoinOrange), + valueFormatter = AmountAxisValueFormatter(state.shouldShowDecimalsInAxis), + ), + bottomAxis = + rememberBottomAxis( + valueFormatter = LabelValueFormatter(axisModel), + ), + ) + } +} + +@Stable +class LabelValueFormatter( + val axisLabels: State>, +) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String = axisLabels.value[value.roundToInt()] +} + +@Stable +class CountAxisValueFormatter : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String = showCount(value.roundToInt()) +} + +@Stable +class AmountAxisValueFormatter( + val showDecimals: Boolean, +) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues, + ): String = + if (showDecimals) { + showAmount(value.toBigDecimal()) + } else { + showAmountAxis(value.toBigDecimal()) + } +} + +var dfG: DecimalFormat = DecimalFormat("#G") +var dfM: DecimalFormat = DecimalFormat("#M") +var dfK: DecimalFormat = DecimalFormat("#k") +var dfN: DecimalFormat = DecimalFormat("#") + +fun showAmountAxis(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.format(amount) + } +}