mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-04-02 17:08:04 +02:00
Migrates Notification Summary to the new state model
This commit is contained in:
parent
32bc8fe667
commit
c64d179f7f
amethyst/src/main/java/com/vitorpamplona/amethyst/ui
navigation
note
screen/loggedIn
@ -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)
|
||||
}
|
||||
}
|
||||
|
303
amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt
Normal file
303
amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt
Normal file
@ -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}")
|
||||
}
|
||||
}
|
210
amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt
Normal file
210
amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryView.kt
Normal file
@ -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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user