Moves to vico chart 2.0

This commit is contained in:
Vitor Pamplona 2025-02-14 11:05:00 -05:00
parent dcb0e31cd6
commit 0f6c3b9a3a
15 changed files with 419 additions and 263 deletions

View File

@ -42,7 +42,7 @@ import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ThreadAssemblerTest {
class ThreadDualAxisChartAssemblerTest {
val db =
"""
[

View File

@ -86,7 +86,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
@ -386,7 +385,7 @@ fun InnerRenderClassifiedsThumb(
card.price?.let {
val priceTag =
remember(card) {
val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountAxis(it) } ?: it.amount
val newAmount = it.amount.toBigDecimalOrNull()?.let { showAmountInteger(it) } ?: it.amount
if (it.frequency != null && it.currency != null) {
"$newAmount ${it.currency}/${it.frequency}"

View File

@ -74,3 +74,10 @@ fun showAmount(amount: BigDecimal?): String {
else -> dfN.get().format(amount)
}
}
fun showAmountWithZero(amount: BigDecimal?): String {
if (amount == null) return "0"
if (amount.abs() < BigDecimal(0.01)) return "0"
return showAmount(amount)
}

View File

@ -0,0 +1,64 @@
/**
* 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.note
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
private val dfG =
object : ThreadLocal<DecimalFormat>() {
override fun initialValue() = DecimalFormat("#G")
}
private val dfM =
object : ThreadLocal<DecimalFormat>() {
override fun initialValue() = DecimalFormat("#M")
}
private val dfK =
object : ThreadLocal<DecimalFormat>() {
override fun initialValue() = DecimalFormat("#k")
}
private val dfN =
object : ThreadLocal<DecimalFormat>() {
override fun initialValue() = DecimalFormat("#")
}
fun showAmountInteger(amount: BigDecimal?): String {
if (amount == null) return ""
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> dfN.get().format(amount)
}
}
fun showAmountIntegerWithZero(amount: BigDecimal?): String {
if (amount == null) return "0"
if (amount.abs() < BigDecimal(0.01)) return "0"
return showAmountInteger(amount)
}

View File

@ -47,7 +47,6 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton
@ -144,7 +143,7 @@ private fun ZapAmount(zapEventNote: Note) {
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount)
val newZapAmount = showAmountInteger((noteState?.note?.event as? LnZapEvent)?.amount)
if (zapAmount != newZapAmount) {
zapAmount = newZapAmount
}

View File

@ -64,11 +64,11 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.SettingsState
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.ammolite.relays.BundledInsert
@ -536,7 +536,7 @@ class AccountViewModel(
it.request.event
?.content
?.ifBlank { null },
showAmountAxis((it.response.event as? LnZapEvent)?.amount),
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
)
}.toMutableMap()
@ -569,7 +569,7 @@ class AccountViewModel(
ZapAmountCommentNotification(
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author,
cachedPrivateRequest.content.ifBlank { null },
showAmountAxis((it.response.event as? LnZapEvent)?.amount),
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
)
} else {
ZapAmountCommentNotification(
@ -577,7 +577,7 @@ class AccountViewModel(
it.request.event
?.content
?.ifBlank { null },
showAmountAxis((it.response.event as? LnZapEvent)?.amount),
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
)
}
} else {
@ -586,7 +586,7 @@ class AccountViewModel(
it.request.event
?.content
?.ifBlank { null },
showAmountAxis((it.response.event as? LnZapEvent)?.amount),
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
)
}
}.toImmutableList()
@ -603,7 +603,7 @@ class AccountViewModel(
ZapAmountCommentNotification(
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author,
cachedPrivateRequest.content.ifBlank { null },
showAmountAxis((it.second?.event as? LnZapEvent)?.amount),
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
)
} else {
ZapAmountCommentNotification(
@ -611,7 +611,7 @@ class AccountViewModel(
it.first.event
?.content
?.ifBlank { null },
showAmountAxis((it.second?.event as? LnZapEvent)?.amount),
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
)
}
} else {
@ -620,7 +620,7 @@ class AccountViewModel(
it.first.event
?.content
?.ifBlank { null },
showAmountAxis((it.second?.event as? LnZapEvent)?.amount),
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
)
}
}.toImmutableList()
@ -642,7 +642,7 @@ class AccountViewModel(
it.first.event
?.content
?.ifBlank { null },
showAmountAxis((it.second?.event as? LnZapEvent)?.amount),
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
)
}.toMutableMap()
@ -687,7 +687,7 @@ class AccountViewModel(
ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount),
showAmountInteger(amount),
),
)
}
@ -698,7 +698,7 @@ class AccountViewModel(
ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content?.ifBlank { null },
showAmountAxis(amount),
showAmountInteger(amount),
),
)
}

View File

@ -22,16 +22,16 @@ 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.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import com.patrykandpatrick.vico.core.common.data.MutableExtraStore
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.showAmountInteger
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.quartz.nip01Core.HexKey
@ -52,37 +52,30 @@ import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
val ShowDecimals = ExtraStore.Key<Boolean>()
val BottomAxisLabelKey = ExtraStore.Key<List<String>>()
@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()
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<CartesianChartModel?>(null)
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
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 { showAmountInteger(it[today()]) }.distinctUntilChanged()
fun formatDate(createAt: Long): String =
sdf.format(
@ -146,10 +139,10 @@ class NotificationSummaryState(
}
this.takenIntoAccount = takenIntoAccount
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
this.reactions.emit(reactions)
this.replies.emit(replies)
this.zaps.emit(zaps)
this.boosts.emit(boosts)
refreshChartModel()
}
@ -159,10 +152,10 @@ class NotificationSummaryState(
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 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
@ -217,10 +210,10 @@ class NotificationSummaryState(
if (hasNewElements) {
this.takenIntoAccount = takenIntoAccount
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
this.reactions.emit(reactions)
this.replies.emit(replies)
this.zaps.emit(zaps)
this.boosts.emit(boosts)
refreshChartModel()
}
@ -229,59 +222,42 @@ class NotificationSummaryState(
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 dataAxisLabelIndexes = listOf(-6, -5, -4, -3, -2, -1, 0)
val dataAxisLabels = dataAxisLabelIndexes.map { sdf.format(now.plusDays(it.toLong())) }
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))
val chart1 =
LineCartesianLayerModel.build {
series(dataAxisLabelIndexes, dataAxisLabels.map { replies.value[it]?.toFloat() ?: 0f })
series(dataAxisLabelIndexes, dataAxisLabels.map { boosts.value[it]?.toFloat() ?: 0f })
series(dataAxisLabelIndexes, dataAxisLabels.map { reactions.value[it]?.toFloat() ?: 0f })
}
}
val chart2 =
LineCartesianLayerModel.build {
series(dataAxisLabelIndexes, dataAxisLabels.map { zaps.value[it]?.toFloat() ?: 0f })
}
val model = CartesianChartModel(chart1, chart2)
val mutableStore = MutableExtraStore()
mutableStore[ShowDecimals] = shouldShowDecimals(chart2.minY, chart2.maxY)
this._chartModel.emit(model.copy(mutableStore))
}
// determine if the min max are so close that they render to the same number.
fun shouldShowDecimals(
min: Float,
max: Float,
min: Double,
max: Double,
): Boolean {
val step = (max - min) / 8
var previous = showAmountAxis(min.toBigDecimal())
var previous = showAmountInteger(min.toBigDecimal())
for (i in 1..7) {
val current = showAmountAxis((min + (i * step)).toBigDecimal())
val current = showAmountInteger((min + (i * step)).toBigDecimal())
if (previous == current) {
return true
}

View File

@ -30,47 +30,17 @@ 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.patrykandpatrick.vico.compose.common.ProvideVicoTheme
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.screen.loggedIn.notifications.chart.ShowChart
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) {
@ -83,137 +53,24 @@ fun SummaryBar(state: NotificationSummaryState) {
enter = slideInVertically() + expandVertically(),
exit = slideOutVertically() + shrinkVertically(),
) {
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 = 0.dp, horizontal = 20.dp)
.clickable(onClick = { showChart = !showChart }),
) {
ProvideChartStyle(
chartStyle = MaterialTheme.colorScheme.chartStyle,
) {
ObserveAndShowChart(state, lineChartCount, lineChartZaps)
ProvideVicoTheme(MaterialTheme.colorScheme.chartStyle) {
ObserveAndShowChart(state)
}
}
}
}
@Composable
private fun ObserveAndShowChart(
state: NotificationSummaryState,
lineChartCount: LineChart,
lineChartZaps: LineChart,
) {
val axisModel = state.axisLabels.collectAsStateWithLifecycle()
private fun ObserveAndShowChart(state: NotificationSummaryState) {
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)
ShowChart(it)
}
}

View File

@ -0,0 +1,43 @@
/**
* 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.chart
import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero
import com.vitorpamplona.amethyst.ui.note.showAmountWithZero
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals
@Stable
class AmountValueFormatter : CartesianValueFormatter {
override fun format(
context: CartesianMeasuringContext,
value: Double,
verticalAxisPosition: Axis.Position.Vertical?,
): CharSequence =
if (context.model.extraStore[ShowDecimals]) {
showAmountWithZero(value.toBigDecimal())
} else {
showAmountIntegerWithZero(value.toBigDecimal())
}
}

View File

@ -0,0 +1,47 @@
/**
* 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.chart
import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import kotlin.math.roundToInt
@Stable
class CountAxisValueFormatter : CartesianValueFormatter {
private fun showCountChart(count: Int?): String {
if (count == null) return "0"
return when {
count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
count >= 10000 -> "${(count / 1000f).roundToInt()}k"
else -> "$count"
}
}
override fun format(
context: CartesianMeasuringContext,
value: Double,
verticalAxisPosition: Axis.Position.Vertical?,
): CharSequence = showCountChart(value.roundToInt())
}

View File

@ -0,0 +1,51 @@
/**
* 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.chart
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext
import com.patrykandpatrick.vico.core.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
@Stable
class LastWeekLabelFormatter : CartesianValueFormatter {
private val displayAxisFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE")
private val now = LocalDateTime.now()
private val cache = LruCache<Int, String>(10)
override fun format(
context: CartesianMeasuringContext,
value: Double,
verticalAxisPosition: Axis.Position.Vertical?,
): CharSequence {
val key = value.roundToInt()
cache[key]?.let { return it }
val text = displayAxisFormatter.format(now.plusDays(key.toLong()))
cache.put(key, text)
return text
}
}

View File

@ -0,0 +1,101 @@
/**
* 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.chart
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.common.fill
import com.patrykandpatrick.vico.core.cartesian.axis.Axis
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.shader.ShaderProvider
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
fun makeLine(color: Color): LineCartesianLayer.Line =
LineCartesianLayer.Line(
fill = LineCartesianLayer.LineFill.single(fill(color)),
areaFill =
LineCartesianLayer.AreaFill.single(
fill(
ShaderProvider.verticalGradient(
color.copy(alpha = 0.4f).toArgb(),
Color.Transparent.toArgb(),
),
),
),
pointConnector = LineCartesianLayer.PointConnector.cubic(),
)
val chartLayers =
arrayOf(
LineCartesianLayer(
LineCartesianLayer.LineProvider.series(
makeLine(RoyalBlue),
makeLine(Color.Green),
makeLine(Color.Red),
),
verticalAxisPosition = Axis.Position.Vertical.Start,
),
LineCartesianLayer(
LineCartesianLayer.LineProvider.series(
makeLine(BitcoinOrange),
),
verticalAxisPosition = Axis.Position.Vertical.End,
),
)
@Composable
fun ShowChart(model: CartesianChartModel) {
val chart =
rememberCartesianChart(
layers = chartLayers,
startAxis =
VerticalAxis.rememberStart(
valueFormatter = CountAxisValueFormatter(),
itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }),
),
endAxis =
VerticalAxis.rememberEnd(
label = rememberAxisLabelComponent(color = BitcoinOrange),
valueFormatter = AmountValueFormatter(),
itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }),
),
bottomAxis =
HorizontalAxis.rememberBottom(
valueFormatter = LastWeekLabelFormatter(),
),
)
CartesianChartHost(
chart = chart,
model = model,
)
}

View File

@ -145,6 +145,7 @@ import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
@ -159,7 +160,6 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.RenderGalleryFeed
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.stringRes
@ -729,7 +729,7 @@ private fun ZapTabHeader(baseUser: User) {
}
}
Text(text = "${showAmountAxis(zapAmount)} ${stringRes(id = R.string.zaps)}")
Text(text = "${showAmountInteger(zapAmount)} ${stringRes(id = R.string.zaps)}")
}
@Composable

View File

@ -58,8 +58,8 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.style.ChartStyle
import com.patrykandpatrick.vico.core.DefaultColors
import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@ -441,22 +441,34 @@ val ColorScheme.largeRelayIconModifier: Modifier
val ColorScheme.selectedReactionBoxModifier: Modifier
get() = if (isLight) LightSelectedReactionBoxModifier else DarkSelectedReactionBoxModifier
val ColorScheme.chartStyle: ChartStyle
get() {
val defaultColors = if (isLight) DefaultColors.Light else DefaultColors.Dark
return ChartStyle.fromColors(
axisLabelColor = Color(defaultColors.axisLabelColor),
axisGuidelineColor = Color(defaultColors.axisGuidelineColor),
axisLineColor = Color(defaultColors.axisLineColor),
entityColors =
listOf(
defaultColors.entity1Color,
defaultColors.entity2Color,
defaultColors.entity3Color,
).map(::Color),
elevationOverlayColor = Color(defaultColors.elevationOverlayColor),
)
}
val chartLightColors =
VicoTheme(
candlestickCartesianLayerColors =
CandlestickCartesianLayerColors(
Color(0xff0ac285),
Color(0xff000000),
Color(0xffe8304f),
),
columnCartesianLayerColors = listOf(Color(0xff3287ff), Color(0xff0ac285), Color(0xffffab02)),
lineColor = Color(0xffbcbfc2),
textColor = Color(0xff000000),
)
val chartDarkColors =
VicoTheme(
candlestickCartesianLayerColors =
CandlestickCartesianLayerColors(
Color(0xff0ac285),
Color(0xffffffff),
Color(0xffe8304f),
),
columnCartesianLayerColors = listOf(Color(0xff3287ff), Color(0xff0ac285), Color(0xffffab02)),
lineColor = Color(0xff494c50),
textColor = Color(0xffffffff),
)
val ColorScheme.chartStyle: VicoTheme
get() = if (isLight) chartLightColors else chartDarkColors
@Composable
fun AmethystTheme(

View File

@ -46,7 +46,7 @@ torAndroid = "0.4.8.12"
translate = "17.0.3"
unifiedpush = "2.3.1"
urlDetector = "0.1.23"
vico-charts = "1.16.0"
vico-charts = "2.0.1"
zelory = "3.0.1"
zoomable = "2.0.0"
zxing = "3.5.3"