Migrates Notification Summary to the new state model

This commit is contained in:
Vitor Pamplona 2024-08-13 18:56:53 -04:00
parent 32bc8fe667
commit c64d179f7f
8 changed files with 550 additions and 532 deletions

@ -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,

@ -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<Map<String, Int>>(emptyMap())
private var _boosts = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _zaps = MutableStateFlow<Map<String, BigDecimal>>(emptyMap())
private var _replies = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _chartModel = MutableStateFlow<ComposedChartEntryModel<ChartEntryModel>?>(null)
private var _axisLabels = MutableStateFlow<List<String>>(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<HexKey>()
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<String, Int>()
val boosts = mutableMapOf<String, Int>()
val zaps = mutableMapOf<String, BigDecimal>()
val replies = mutableMapOf<String, Int>()
val takenIntoAccount = mutableSetOf<HexKey>()
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<Set<Note>>) {
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<Set<Note>>(250, Dispatchers.IO)
fun invalidateInsertData(newItems: Set<Note>) {
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 <UserReactionsViewModel : ViewModel> create(modelClass: Class<UserReactionsViewModel>): 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,

@ -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<Note>) {
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()
}
}

@ -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() {

@ -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,

@ -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<List<String>>,
) : AxisValueFormatter<AxisPosition.Horizontal.Bottom> {
override fun formatValue(
value: Float,
chartValues: ChartValues,
): String = axisLabels.value[value.roundToInt()]
}
@Stable
class CountAxisValueFormatter : AxisValueFormatter<AxisPosition.Vertical.Start> {
override fun formatValue(
value: Float,
chartValues: ChartValues,
): String = showCount(value.roundToInt())
}
@Stable
class AmountAxisValueFormatter(
val showDecimals: Boolean,
) : AxisValueFormatter<AxisPosition.Vertical.End> {
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)
}
}

@ -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<Map<String, Int>>(emptyMap())
private var _boosts = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _zaps = MutableStateFlow<Map<String, BigDecimal>>(emptyMap())
private var _replies = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _chartModel = MutableStateFlow<ComposedChartEntryModel<ChartEntryModel>?>(null)
private var _axisLabels = MutableStateFlow<List<String>>(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<HexKey>()
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<String, Int>()
val boosts = mutableMapOf<String, Int>()
val zaps = mutableMapOf<String, BigDecimal>()
val replies = mutableMapOf<String, Int>()
val takenIntoAccount = mutableSetOf<HexKey>()
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<Set<Note>>) {
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<Set<Note>>(250, Dispatchers.IO)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { addToStatsSuspend(it) }
}
fun destroy() {
bundlerInsert.cancel()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
}

@ -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<List<String>>,
) : AxisValueFormatter<AxisPosition.Horizontal.Bottom> {
override fun formatValue(
value: Float,
chartValues: ChartValues,
): String = axisLabels.value[value.roundToInt()]
}
@Stable
class CountAxisValueFormatter : AxisValueFormatter<AxisPosition.Vertical.Start> {
override fun formatValue(
value: Float,
chartValues: ChartValues,
): String = showCount(value.roundToInt())
}
@Stable
class AmountAxisValueFormatter(
val showDecimals: Boolean,
) : AxisValueFormatter<AxisPosition.Vertical.End> {
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)
}
}