From 104946e04709a56585f47b82d38dd71c761b46d2 Mon Sep 17 00:00:00 2001 From: Guilherme Brisson <54915600+guiBrisson@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:22:13 -0300 Subject: [PATCH] Operation history (#24) * 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. --- .../repository/OperationRepositoryImpl.kt | 10 +- .../kotlin/domain/model/Category.kt | 6 +- .../designsystem/OperationHistoryItem.kt | 122 +++++++++++++++++ .../designsystem/util/ShimmerEffect.kt | 41 ++++++ .../kotlin/presentation/di/ViewModelModule.kt | 2 +- .../presentation/screen/home/HomeScreen.kt | 128 ++++++++++++++++-- .../presentation/screen/home/HomeViewModel.kt | 32 ++++- .../kotlin/presentation/theme/Dimens.kt | 6 +- 8 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/presentation/designsystem/OperationHistoryItem.kt create mode 100644 composeApp/src/commonMain/kotlin/presentation/designsystem/util/ShimmerEffect.kt diff --git a/composeApp/src/commonMain/kotlin/data/repository/OperationRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/data/repository/OperationRepositoryImpl.kt index c1dd664..113781f 100644 --- a/composeApp/src/commonMain/kotlin/data/repository/OperationRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/data/repository/OperationRepositoryImpl.kt @@ -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 { - return localOperationDataSource.getById(id) + override suspend fun getById(id: Int): Result = withContext(Dispatchers.IO) { + localOperationDataSource.getById(id) } - override suspend fun getAll(): List = + override suspend fun getAll(): List = withContext(Dispatchers.IO) { localOperationDataSource.getAll() + } override fun insertNew(amount: String, description: String, type: OperationType, category: Category, isPeriodic: Boolean diff --git a/composeApp/src/commonMain/kotlin/domain/model/Category.kt b/composeApp/src/commonMain/kotlin/domain/model/Category.kt index d81d91d..48d1266 100644 --- a/composeApp/src/commonMain/kotlin/domain/model/Category.kt +++ b/composeApp/src/commonMain/kotlin/domain/model/Category.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/presentation/designsystem/OperationHistoryItem.kt b/composeApp/src/commonMain/kotlin/presentation/designsystem/OperationHistoryItem.kt new file mode 100644 index 0000000..b45dce2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/presentation/designsystem/OperationHistoryItem.kt @@ -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()) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/presentation/designsystem/util/ShimmerEffect.kt b/composeApp/src/commonMain/kotlin/presentation/designsystem/util/ShimmerEffect.kt new file mode 100644 index 0000000..6e9bbc9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/presentation/designsystem/util/ShimmerEffect.kt @@ -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 } +} diff --git a/composeApp/src/commonMain/kotlin/presentation/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/presentation/di/ViewModelModule.kt index dd76017..b7364c6 100644 --- a/composeApp/src/commonMain/kotlin/presentation/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/presentation/di/ViewModelModule.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeScreen.kt index c2530b8..a0076b5 100644 --- a/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeScreen.kt @@ -1,22 +1,36 @@ 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( @@ -24,22 +38,35 @@ fun HomeRoute( 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>, 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" @@ -47,12 +74,93 @@ internal fun HomeScreen( } } +private fun LazyListScope.historySuccessUiState(uiState: UIState.Success>) { + 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> = UIState.Empty + + HomeScreen(onNewOperation = { }, historyUiState = historyUiState) } } } diff --git a/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeViewModel.kt b/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeViewModel.kt index 80a4de3..e905a98 100644 --- a/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/presentation/screen/home/HomeViewModel.kt @@ -1,8 +1,38 @@ package presentation.screen.home +import domain.model.Operation +import domain.model.UIState +import domain.repository.OperationRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import moe.tlaster.precompose.viewmodel.ViewModel +import moe.tlaster.precompose.viewmodel.viewModelScope -class HomeViewModel : ViewModel() { +class HomeViewModel( + private val operationRepository: OperationRepository, +) : ViewModel() { + private val _historyUiState = MutableStateFlow>>(UIState.Loading) + val historyUiState: StateFlow>> = _historyUiState.asStateFlow() + fun fetchOperations() { + _historyUiState.value = UIState.Loading + viewModelScope.launch { + try { + val operations = operationRepository.getAll() + + if (operations.isEmpty()) { + _historyUiState.value = UIState.Empty + return@launch + } + + _historyUiState.value = UIState.Success(operations) + } catch (e: Exception) { + e.printStackTrace() + e.cause?.let { t -> _historyUiState.value = UIState.Error(t) } + } + } + } } diff --git a/composeApp/src/commonMain/kotlin/presentation/theme/Dimens.kt b/composeApp/src/commonMain/kotlin/presentation/theme/Dimens.kt index 3a23604..7ca8f00 100644 --- a/composeApp/src/commonMain/kotlin/presentation/theme/Dimens.kt +++ b/composeApp/src/commonMain/kotlin/presentation/theme/Dimens.kt @@ -3,8 +3,11 @@ package presentation.theme import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +val PADDING_50 = 50.dp +val PADDING_32 = 32.dp val PADDING_28 = 28.dp val PADDING_24 = 24.dp +val PADDING_20 = 20.dp val PADDING_16 = 16.dp val PADDING_12 = 12.dp val PADDING_8 = 8.dp @@ -15,6 +18,7 @@ val ZERO_DP = 0.dp val CORNER_RADIUS_4 = 4.dp val CORNER_RADIUS_22 = 22.dp +val FONT_12 = 12.sp val FONT_14 = 14.sp val FONT_16 = 16.sp val FONT_32 = 32.sp @@ -25,4 +29,4 @@ val MEDIUM_INPUT_HEIGHT = 72.dp val MIN_INPUT_HEIGHT = 48.dp val INPUT_HEIGHT = 52.dp val BUTTON_PROGRESS_HEIGHT = 18.dp -val SNACK_BAR_DURATION = 2000L \ No newline at end of file +const val SNACK_BAR_DURATION = 2000L \ No newline at end of file