Skip to content

Commit

Permalink
Operation history (#24)
Browse files Browse the repository at this point in the history
* Operation history item composable

The composable for operation history is created and being handled by the main historyUiState. Is yet to be defined other states (empty, error and loading).

* Handle the operation history loading ui state

Added the shimmer effect utility modifier.
  • Loading branch information
guiBrisson authored Jun 4, 2024
1 parent 12b2f1b commit 104946e
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ import domain.model.Operation
import domain.model.OperationType
import domain.model.UIState
import domain.repository.OperationRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext


class OperationRepositoryImpl(
private val localOperationDataSource: OperationDataSource,
) : OperationRepository {
override suspend fun getById(id: Int): Result<Operation> {
return localOperationDataSource.getById(id)
override suspend fun getById(id: Int): Result<Operation> = withContext(Dispatchers.IO) {
localOperationDataSource.getById(id)
}

override suspend fun getAll(): List<Operation> =
override suspend fun getAll(): List<Operation> = withContext(Dispatchers.IO) {
localOperationDataSource.getAll()
}

override fun insertNew(amount: String, description: String, type: OperationType,
category: Category, isPeriodic: Boolean
Expand Down
6 changes: 4 additions & 2 deletions composeApp/src/commonMain/kotlin/domain/model/Category.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ import presentation.theme.rose600

@OptIn(ExperimentalResourceApi::class)
enum class Category(
val categoryName: String, val primaryColor: Color,
val secondaryColor: Color, val icon: DrawableResource,
val categoryName: String,
val primaryColor: Color,
val secondaryColor: Color,
val icon: DrawableResource,
) {
HEALTH_WELLNESS("Health & Wellness", red600, red500, Res.drawable.ic_health),
ENTERTAINMENT("Entertainment", orange600, orange500, Res.drawable.ic_entertainment),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package presentation.designsystem

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import domain.model.Operation
import kotlinx.datetime.LocalTime
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import presentation.theme.MoneyMateTheme
import presentation.theme.*

@OptIn(ExperimentalResourceApi::class)
@Composable
fun OperationHistoryItem(
modifier: Modifier = Modifier,
operation: Operation,
) {
Row(
modifier = modifier then Modifier
.clip(RoundedCornerShape(PADDING_4))
.background(MaterialTheme.colors.surface)
.padding(PADDING_8)
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(PADDING_8),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(PADDING_50)
.clip(RoundedCornerShape(PADDING_4))
.background(operation.category.secondaryColor.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(PADDING_20),
painter = painterResource(operation.category.icon),
contentDescription = "Category ${operation.category.categoryName} icon",
tint = operation.category.primaryColor,
)
}

Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Text(
text = operation.category.categoryName,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Medium,
fontSize = FONT_16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

Text(
text = operation.description,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.69f),
fontWeight = FontWeight.Normal,
fontSize = FONT_12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

Column(
modifier = Modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
) {
// this is a workaround to accomplish the same result as String.format("%.2f")
val splitAmount = (operation.amount / 100).toString().split(".")
var cents = splitAmount.last()
if (cents.length <= 1) cents = "${cents}0"
val formattedAmount = "$ ${splitAmount.first()}.$cents"

val formattedTime = operation.date?.time?.format(
LocalTime.Format { hour(); char(':'); minute() }
) ?: ""

Text(
text = formattedAmount,
color = operation.type.color,
fontWeight = FontWeight.Medium,
fontSize = FONT_16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontFamily = oswaldFontFamily(),
)

Text(
text = formattedTime,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.69f),
fontWeight = FontWeight.Normal,
fontSize = FONT_12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

}
}

@Composable
@Preview
private fun PreviewOperationHistoryItem() {
MoneyMateTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
OperationHistoryItem(operation = Operation())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package presentation.designsystem.util

import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.material.MaterialTheme
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.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize

fun Modifier.shimmerEffect(): Modifier = composed {
var size by remember { mutableStateOf(IntSize.Zero) }
val transition = rememberInfiniteTransition()
val startOffsetX by transition.animateFloat(
initialValue = -2 * size.width.toFloat(),
targetValue = 2 * size.width.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(1000)
)
)
val brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colors.surface,
MaterialTheme.colors.onSurface.copy(0.1f),
MaterialTheme.colors.surface,
),
start = Offset(startOffsetX, 0f),
end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
)

background(brush = brush).onGloballyPositioned { size = it.size }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import presentation.screen.home.HomeViewModel
import presentation.screen.operation.NewOperationViewModel

val viewModelModule = module {
factory { HomeViewModel() }
factory { HomeViewModel(get()) }
factory { NewOperationViewModel(get()) }
factory { BalanceViewModel(get()) }
factory { CategoryViewModel() }
Expand Down
128 changes: 118 additions & 10 deletions composeApp/src/commonMain/kotlin/presentation/screen/home/HomeScreen.kt
Original file line number Diff line number Diff line change
@@ -1,58 +1,166 @@
package presentation.screen.home

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import domain.model.Operation
import domain.model.UIState
import kotlinx.datetime.LocalDate
import kotlinx.datetime.format
import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.char
import moe.tlaster.precompose.flow.collectAsStateWithLifecycle
import moe.tlaster.precompose.koin.koinViewModel
import org.jetbrains.compose.ui.tooling.preview.Preview
import presentation.designsystem.FloatingButton
import presentation.theme.MoneyMateTheme
import presentation.designsystem.OperationHistoryItem
import presentation.designsystem.util.shimmerEffect
import presentation.theme.*

@Composable
fun HomeRoute(
modifier: Modifier = Modifier,
onNewOperation: () -> Unit,
) {
val viewModel = koinViewModel(HomeViewModel::class)
val historyUiState by viewModel.historyUiState.collectAsStateWithLifecycle()

HomeScreen(modifier = modifier, onNewOperation)
LaunchedEffect(Unit) { viewModel.fetchOperations() }

HomeScreen(
modifier = modifier,
historyUiState = historyUiState,
onNewOperation = onNewOperation,
)
}

@Composable
internal fun HomeScreen(
modifier: Modifier = Modifier,
historyUiState: UIState<List<Operation>>,
onNewOperation: () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = modifier.fillMaxSize()) {
Text(text = "HOME")
LazyColumn(modifier = modifier.fillMaxSize()) {
//TODO: the `error` state is waiting the design and the `empty` should be handled on the whole screen
when (historyUiState) {
UIState.Loading -> historyLoadingUiState()
is UIState.Success -> historySuccessUiState(historyUiState)
else -> Unit
}
}

FloatingButton(
modifier = Modifier.align(Alignment.BottomEnd).padding(20.dp),
modifier = Modifier.align(Alignment.BottomEnd).padding(PADDING_20),
onClick = onNewOperation,
imageVector = Icons.Default.Add,
contentDescription = "Add new operation"
)
}
}

private fun LazyListScope.historySuccessUiState(uiState: UIState.Success<List<Operation>>) {
val mappedByDate = uiState.result.groupBy { it.date?.date }

item {
Text(
modifier = Modifier.padding(top = PADDING_32, start = PADDING_20, end = PADDING_20),
text = "History",
fontSize = FONT_16,
fontWeight = FontWeight.SemiBold,
)
}

for (operationDate in mappedByDate) {
val dateTime = operationDate.key
val operations = operationDate.value.sortedBy { it.date }.reversed() // Latest first

item {
val formattedTime = dateTime?.format(LocalDate.Format {
dayOfMonth(); char(' '); monthName(MonthNames.ENGLISH_FULL)
}) ?: "Unknown"

Text(
modifier = Modifier.padding(top = PADDING_20, bottom = PADDING_12).padding(horizontal = PADDING_20),
text = formattedTime,
color = MaterialTheme.colors.onBackground.copy(alpha = 0.6f),
fontSize = FONT_12,
fontWeight = FontWeight.Medium,
)
}


itemsIndexed(operations) { index, operation ->
val bottomPadding = if (index == operations.lastIndex) ZERO_DP else PADDING_4

OperationHistoryItem(
modifier = Modifier.padding(start = PADDING_20, end = PADDING_20, bottom = bottomPadding),
operation = operation,
)
}
}
}

private fun LazyListScope.historyLoadingUiState() {
item {
Box(
modifier = Modifier
.padding(top = PADDING_32, start = PADDING_20, end = PADDING_20)
.clip(RoundedCornerShape(4.dp))
.height(25.dp)
.width(60.dp)
.shimmerEffect(),
)
}

item {
Box(
modifier = Modifier
.padding(top = PADDING_20, start = PADDING_20, end = PADDING_20, bottom = PADDING_12)
.clip(RoundedCornerShape(4.dp))
.height(20.dp)
.width(50.dp)
.shimmerEffect(),
)
}

items(5) {
val bottomPadding = if (it == 3) ZERO_DP else PADDING_4

Box(
modifier = Modifier
.padding(start = PADDING_20, end = PADDING_20, bottom = bottomPadding)
.clip(RoundedCornerShape(4.dp))
.height(54.dp)
.fillMaxWidth()
.shimmerEffect(),
)
}
}

@Composable
@Preview
private fun PreviewHomeScreen() {
MoneyMateTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
HomeScreen(onNewOperation = { })
val historyUiState: UIState<List<Operation>> = UIState.Empty

HomeScreen(onNewOperation = { }, historyUiState = historyUiState)
}
}
}
Loading

0 comments on commit 104946e

Please sign in to comment.