diff --git a/app/build.gradle b/app/build.gradle index fb00fb23e..0d4408282 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 167 - versionName "0.49.4" + versionCode 168 + versionName "0.50.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -177,6 +177,12 @@ dependencies { playImplementation platform('com.google.firebase:firebase-bom:32.0.0') playImplementation 'com.google.firebase:firebase-messaging-ktx' + // Charts + implementation "com.patrykandpatrick.vico:core:${vico_version}" + implementation "com.patrykandpatrick.vico:compose:${vico_version}" + implementation "com.patrykandpatrick.vico:views:${vico_version}" + implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}" + // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index aba906e2a..8a2ceecee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { BadgeAwardEvent.kind ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 400, + limit = 4000, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt new file mode 100644 index 000000000..5203edd1a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -0,0 +1,245 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.viewModelScope +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.ReactionEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +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, accountViewModel: AccountViewModel, navController: NavController, onClick: () -> Unit) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.clickable(onClick = onClick).padding(10.dp)) { + Text( + text = "Today", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + modifier = Modifier.width(65.dp) + ) + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserReplyReaction(model.replies[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserBoostReaction(model.boosts[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserLikeReaction(model.replies[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserZapReaction(model.zaps[model.today]) + } + } +} + +class UserReactionsViewModel : ViewModel() { + var user: User? = null + + var reactions by mutableStateOf>(emptyMap()) + var boosts by mutableStateOf>(emptyMap()) + var zaps by mutableStateOf>(emptyMap()) + var replies by mutableStateOf>(emptyMap()) + + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + val today = sdf.format(LocalDateTime.now()) + + fun load(baseUser: User) { + user = baseUser + reactions = emptyMap() + boosts = emptyMap() + zaps = emptyMap() + replies = emptyMap() + } + + fun refresh() { + val scope = CoroutineScope(Job() + Dispatchers.Default) + scope.launch { + refreshSuspended() + } + } + + fun formatDate(createAt: Long): String { + return sdf.format( + Instant.ofEpochSecond(createAt) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + ) + } + + fun refreshSuspended() { + val currentUser = user?.pubkeyHex ?: return + + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + + LocalCache.notes.values.forEach { + val noteEvent = it.event + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser)) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + } + } + if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser)) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + } + } + if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser)) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + } + } + if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser)) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + } + } + } + + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + } + + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + refresh() + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + super.onCleared() + } +} + +@Composable +fun UserReplyReaction( + replyCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(20.dp), + tint = Color.Cyan + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(replyCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserBoostReaction( + boostCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(boostCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserLikeReaction( + likeCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(likeCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserZapReaction( + amount: BigDecimal? +) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + showAmount(amount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 9046a1569..aab31e69e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -1,25 +1,64 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +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.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +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.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.endAxis +import com.patrykandpatrick.vico.compose.axis.vertical.startAxis +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.ComposedChartEntryModel +import com.patrykandpatrick.vico.core.chart.composed.plus +import com.patrykandpatrick.vico.core.chart.line.LineChart +import com.patrykandpatrick.vico.core.chart.values.ChartValues +import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders +import com.patrykandpatrick.vico.core.entry.ChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer +import com.patrykandpatrick.vico.core.entry.composed.plus +import com.patrykandpatrick.vico.core.entry.entryOf import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.UserReactionsRow +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel +import com.vitorpamplona.amethyst.ui.note.showAmount +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt @Composable fun NotificationScreen( @@ -61,6 +100,7 @@ fun NotificationScreen( Column( modifier = Modifier.padding(vertical = 0.dp) ) { + SummaryBar(accountViewModel, navController) CardFeedView( viewModel = notifFeedViewModel, accountViewModel = accountViewModel, @@ -72,3 +112,143 @@ fun NotificationScreen( } } } + +@Composable +fun SummaryBar(accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val accountUser = remember(accountState) { accountState?.account?.userProfile() } ?: return + + val model: UserReactionsViewModel = viewModel() + + var chartModel by remember(accountState) { mutableStateOf?>(null) } + var axisLabels by remember(accountState) { mutableStateOf>(emptyList()) } + + val scope = rememberCoroutineScope() + + var showChart by remember { + mutableStateOf(false) + } + + LaunchedEffect(accountUser.pubkeyHex) { + scope.launch { + model.load(accountUser) + model.refreshSuspended() + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") + + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { model.sdf.format(now.minusSeconds(day * it)) } + axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } + + val listOfCountCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.replies[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.boosts[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.reactions[dateStr]?.toFloat() ?: 0f) } + ) + + val listOfValueCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, model.zaps[dateStr]?.toFloat() ?: 0f) } + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) + } + } + + UserReactionsRow(model, accountViewModel, navController) { + showChart = !showChart + } + + val lineChartCount = + lineChart( + lines = listOf(Color.Cyan, 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 + ) + + chartModel?.let { + if (showChart) { + Row(modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp).clickable(onClick = { showChart = !showChart })) { + ProvideChartStyle() { + Chart( + chart = remember(lineChartCount, lineChartZaps) { + lineChartCount.plus(lineChartZaps) + }, + model = it, + startAxis = startAxis( + valueFormatter = CountAxisValueFormatter() + ), + endAxis = endAxis( + valueFormatter = AmountAxisValueFormatter() + ), + bottomAxis = bottomAxis( + valueFormatter = LabelValueFormatter(axisLabels) + ) + ) + } + } + } + } + + Divider( + thickness = 0.25.dp + ) +} + +class LabelValueFormatter(val axisLabels: List) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return axisLabels[value.roundToInt()] + } +} + +class CountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showCount(value.roundToInt()) + } +} + +class AmountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showAmount(value.toBigDecimal()) + } +} diff --git a/build.gradle b/build.gradle index 09111b578..4d3a662e0 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { room_version = "2.4.3" accompanist_version = '0.30.0' coil_version = '2.3.0' + vico_version = '1.6.5' } dependencies { classpath 'com.google.gms:google-services:4.3.15'