diff --git a/app/src/main/java/br/com/stonks/navigation/MainNavDestination.kt b/app/src/main/java/br/com/stonks/navigation/MainNavDestination.kt new file mode 100644 index 0000000..0603a0f --- /dev/null +++ b/app/src/main/java/br/com/stonks/navigation/MainNavDestination.kt @@ -0,0 +1,6 @@ +package br.com.stonks.navigation + +enum class MainNavDestination(val route: String) { + HOME("home"), + STOCK("stock"), +} diff --git a/app/src/main/java/br/com/stonks/ui/view/MainActivity.kt b/app/src/main/java/br/com/stonks/ui/view/MainActivity.kt index d9d8754..544da35 100644 --- a/app/src/main/java/br/com/stonks/ui/view/MainActivity.kt +++ b/app/src/main/java/br/com/stonks/ui/view/MainActivity.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import br.com.stonks.feature.home.ui.view.HomeRemoteScreen +import br.com.stonks.feature.home.ui.view.HomeScreen import br.com.stonks.feature.stocks.ui.view.StockAlertScreen import br.com.stonks.navigation.MainNavDestination @@ -53,7 +53,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.padding(innerPadding) ) { composable(route = MainNavDestination.HOME.route) { - HomeRemoteScreen( + HomeScreen( snackbarHostState = snackbarHostState, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d49c73c..c6fcf1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Stonks - \ No newline at end of file + Home + Stock Alert + diff --git a/design-system/src/main/res/drawable/ic_more.xml b/design-system/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..e4c7c00 --- /dev/null +++ b/design-system/src/main/res/drawable/ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/di/HomeModule.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/di/HomeModule.kt index fb62b9a..eb4680b 100644 --- a/feature/home/src/main/kotlin/br/com/stonks/feature/home/di/HomeModule.kt +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/di/HomeModule.kt @@ -1,5 +1,6 @@ package br.com.stonks.feature.home.di +import br.com.stonks.common.states.ViewModelState import br.com.stonks.feature.home.domain.mapper.DailyTransactionMapper import br.com.stonks.feature.home.domain.mapper.WalletMapper import br.com.stonks.feature.home.domain.usecase.DailyTransactionUseCase @@ -9,9 +10,13 @@ import br.com.stonks.feature.home.repository.HomeRepository import br.com.stonks.feature.home.repository.HomeRepositoryImpl import br.com.stonks.feature.home.repository.remote.HomeApiService import br.com.stonks.feature.home.repository.remote.HomeRemoteDataSource +import br.com.stonks.feature.home.ui.mapper.HomeUiMapper +import br.com.stonks.feature.home.ui.viewmodel.HOME_VM_QUALIFIER import br.com.stonks.feature.home.ui.viewmodel.HomeViewModel import br.com.stonks.infrastructure.network.provider.NetworkServiceProvider import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.bind import org.koin.dsl.module val homeModule = module { @@ -67,10 +72,10 @@ val homeModule = module { ) } - viewModel { + viewModel(qualifier = named(HOME_VM_QUALIFIER)) { HomeViewModel( homeContentUseCase = get(), homeUiMapper = get(), ) - } + } bind ViewModelState::class } diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/domain/usecase/HomeContentUseCase.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/domain/usecase/HomeContentUseCase.kt new file mode 100644 index 0000000..772064c --- /dev/null +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/domain/usecase/HomeContentUseCase.kt @@ -0,0 +1,17 @@ +package br.com.stonks.feature.home.domain.usecase + +import br.com.stonks.feature.home.domain.model.HomeContentModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +internal class HomeContentUseCase( + private val walletUseCase: WalletUseCase, + private val dailyTransactionUseCase: DailyTransactionUseCase, +) { + + suspend fun fetchData(): Flow { + return walletUseCase().combine(dailyTransactionUseCase()) { wallet, transactions -> + HomeContentModel(wallet, transactions) + } + } +} diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/HomeUiMapper.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/HomeUiMapper.kt new file mode 100644 index 0000000..70d4107 --- /dev/null +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/HomeUiMapper.kt @@ -0,0 +1,64 @@ +package br.com.stonks.feature.home.ui.mapper + +import br.com.stonks.common.formatters.DATE_PATTERN_DD_MMMM_BR +import br.com.stonks.common.formatters.formatTo +import br.com.stonks.common.mapper.Mapper +import br.com.stonks.designsystem.components.PieChartData +import br.com.stonks.designsystem.components.PieChartDataProgress +import br.com.stonks.feature.home.domain.model.DailyTransactionModel +import br.com.stonks.feature.home.domain.model.HomeContentModel +import br.com.stonks.feature.home.domain.model.PortfolioModel +import br.com.stonks.feature.home.domain.model.TransactionModel +import br.com.stonks.feature.home.domain.model.WalletModel +import br.com.stonks.feature.home.ui.model.DailyTransactionUiModel +import br.com.stonks.feature.home.ui.model.HomeUiModel +import br.com.stonks.feature.home.ui.model.PortfolioUiModel +import br.com.stonks.feature.home.ui.model.TransactionUiModel + +internal class HomeUiMapper : Mapper { + + override fun mapper(input: HomeContentModel) = HomeUiModel( + totalAssets = input.wallet.totalAssets, + portfolioChart = input.wallet.toPieChart(), + portfolio = input.wallet.portfolio.map(::mapperPortfolio), + dailyTransactions = input.dailyTransactions.map(::mapperDailyGroup), + ) + + private fun WalletModel.toPieChart(): PieChartData { + var incrementalProgress = 0f + + val dataProgress = this.portfolio.map { + incrementalProgress += it.allocation + + PieChartDataProgress( + progress = incrementalProgress, + progressColor = it.portfolioType.getColor() + ) + }.sortedByDescending { it.progress } + + return PieChartData( + title = "Todos os produtos", + value = this.investedBalance, + dataProgress = dataProgress, + ) + } + + private fun mapperPortfolio(input: PortfolioModel) = PortfolioUiModel( + tagColor = input.portfolioType.getColor(), + portfolioName = input.portfolioName, + totalInvestment = input.totalInvestment, + allocation = input.allocation, + ) + + private fun mapperDailyGroup(input: DailyTransactionModel) = DailyTransactionUiModel( + dateGroup = input.date.formatTo(DATE_PATTERN_DD_MMMM_BR), + dailyBalance = input.dailyBalance, + transactions = input.transactions.map(::mapperTransaction), + ) + + private fun mapperTransaction(input: TransactionModel) = TransactionUiModel( + icon = input.type.getIcon(), + description = input.description, + value = input.value, + ) +} diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/ViewUtils.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/ViewUtils.kt new file mode 100644 index 0000000..aa344ed --- /dev/null +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/mapper/ViewUtils.kt @@ -0,0 +1,24 @@ +package br.com.stonks.feature.home.ui.mapper + +import androidx.compose.ui.graphics.Color +import br.com.stonks.designsystem.tokens.ColorToken +import br.com.stonks.feature.home.domain.types.PortfolioType +import br.com.stonks.feature.home.domain.types.TransactionType + +fun PortfolioType.getColor(): Color { + return when (this) { + PortfolioType.DIGITAL_ACCOUNT -> ColorToken.HighlightOrange + PortfolioType.INVESTMENT_FUNDS -> ColorToken.HighlightBlue + PortfolioType.GOVERNMENT_BONDS -> ColorToken.HighlightPurple + PortfolioType.STOCK -> ColorToken.HighlightGreen + else -> ColorToken.Grayscale200 + } +} + +fun TransactionType.getIcon(): Int { + return when (this) { + TransactionType.INCOME -> br.com.stonks.designsystem.R.drawable.ic_income + TransactionType.EXPENSIVE -> br.com.stonks.designsystem.R.drawable.ic_expensive + TransactionType.UNKNOWN -> br.com.stonks.designsystem.R.drawable.ic_more + } +} diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/model/HomeUiModel.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/model/HomeUiModel.kt new file mode 100644 index 0000000..0e2ecad --- /dev/null +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/model/HomeUiModel.kt @@ -0,0 +1,31 @@ +package br.com.stonks.feature.home.ui.model + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import br.com.stonks.designsystem.components.PieChartData + +internal data class PortfolioUiModel( + val tagColor: Color, + val portfolioName: String, + val totalInvestment: Double, + val allocation: Float, +) + +internal data class TransactionUiModel( + @DrawableRes val icon: Int, + val description: String, + val value: Double, +) + +internal data class DailyTransactionUiModel( + val dateGroup: String, + val dailyBalance: Double, + val transactions: List, +) + +internal data class HomeUiModel( + val totalAssets: Double, + val portfolioChart: PieChartData, + val portfolio: List, + val dailyTransactions: List, +) diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/states/HomeUiState.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/states/HomeUiState.kt index 1846bec..0553357 100644 --- a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/states/HomeUiState.kt +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/states/HomeUiState.kt @@ -7,9 +7,7 @@ internal sealed class HomeUiState : UiState { data object Loading : HomeUiState() - data class WalletSuccess(val data: WalletModel) : HomeUiState() + data class Success(val data: HomeUiModel) : HomeUiState() - data class DailyTransactionSuccess(val data: DailyTransactionModel) : HomeUiState() - - data class Error(val exception: StonksApiException) : HomeUiState() + data class Error(val exception: Throwable) : HomeUiState() } diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/HomeScreen.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/HomeScreen.kt new file mode 100644 index 0000000..622449c --- /dev/null +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/HomeScreen.kt @@ -0,0 +1,168 @@ +package br.com.stonks.feature.home.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import br.com.stonks.common.formatters.formatCurrency +import br.com.stonks.common.states.ViewModelState +import br.com.stonks.designsystem.components.HeaderLayout +import br.com.stonks.designsystem.components.PieChartData +import br.com.stonks.designsystem.components.PieChartDataProgress +import br.com.stonks.designsystem.components.PieChartLayout +import br.com.stonks.designsystem.components.SnackbarLayout +import br.com.stonks.designsystem.tokens.ColorToken +import br.com.stonks.designsystem.tokens.SpacingToken +import br.com.stonks.feature.home.R +import br.com.stonks.feature.home.ui.model.DailyTransactionUiModel +import br.com.stonks.feature.home.ui.model.HomeUiModel +import br.com.stonks.feature.home.ui.model.PortfolioUiModel +import br.com.stonks.feature.home.ui.model.TransactionUiModel +import br.com.stonks.feature.home.ui.states.HomeUiState +import br.com.stonks.feature.home.ui.viewmodel.HOME_VM_QUALIFIER +import org.koin.androidx.compose.koinViewModel +import org.koin.core.qualifier.named +import timber.log.Timber + +@Composable +private fun SessionDivider( + thickness: Dp = SpacingToken.sm, +) { + Spacer(modifier = Modifier.height(thickness)) +} + +@Composable +private fun HomeContent( + uiModel: HomeUiModel, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(SpacingToken.xl), + verticalArrangement = Arrangement.spacedBy( + space = SpacingToken.xl, + ), + ) { + item { + HeaderLayout( + title = stringResource(id = R.string.total_balance), + subtitle = uiModel.totalAssets.formatCurrency(), + ) + } + item { + SessionDivider() + Text( + text = stringResource(id = R.string.wallet_title), + style = MaterialTheme.typography.titleMedium, + ) + } + item { + SessionDivider() + PieChartLayout( + data = uiModel.portfolioChart, + ) + } + items(uiModel.portfolio) { + PortfolioCard( + uiModel = it, + ) + } + items(uiModel.dailyTransactions) { group -> + SessionDivider() + TransactionGroupLayout( + title = group.dateGroup, + subtitle = stringResource( + id = R.string.daily_balance, group.dailyBalance.formatCurrency(), + ), + ) + group.transactions.forEach { transaction -> + TransactionItemLayout( + icon = transaction.icon, + description = transaction.description, + value = transaction.value.formatCurrency(), + ) + } + } + } +} + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + viewModel: ViewModelState<*, *> = koinViewModel(qualifier = named(HOME_VM_QUALIFIER)), + snackbarHostState: SnackbarHostState, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + when (uiState.value) { + is HomeUiState.Loading -> { + Timber.i("Loading home screen...") + } + + is HomeUiState.Success -> { + HomeContent( + uiModel = (uiState.value as HomeUiState.Success).data, + modifier = modifier, + ) + } + + is HomeUiState.Error -> { + SnackbarLayout( + snackbarHostState = snackbarHostState, + message = "Ops, algo deu errado tente novamente", + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeScreenPreview() { + HomeContent( + uiModel = HomeUiModel( + totalAssets = 166300.0, + portfolioChart = PieChartData( + title = "Todos os produtos", + value = 160000.0, + dataProgress = listOf( + PieChartDataProgress( + progress = 1f, + progressColor = ColorToken.HighlightGreen, + ), + ), + ), + portfolio = listOf( + PortfolioUiModel( + tagColor = ColorToken.HighlightGreen, + portfolioName = "Ações", + totalInvestment = 1000.0, + allocation = 0.3043f, + ) + ), + dailyTransactions = listOf( + DailyTransactionUiModel( + dateGroup = "05 de Maio", + dailyBalance = 100.0, + transactions = listOf( + TransactionUiModel( + icon = br.com.stonks.designsystem.R.drawable.ic_income, + description = "Resgate", + value = 100.0, + ) + ), + ) + ) + ) + ) +} diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/PortfolioLayout.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/PortfolioLayout.kt index 7ffada2..cdc65ab 100644 --- a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/PortfolioLayout.kt +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/view/PortfolioLayout.kt @@ -31,13 +31,11 @@ import br.com.stonks.designsystem.components.TagLayout import br.com.stonks.designsystem.tokens.ColorToken import br.com.stonks.designsystem.tokens.SpacingToken import br.com.stonks.feature.home.R -import br.com.stonks.feature.home.domain.model.PortfolioModel -import br.com.stonks.feature.home.domain.model.getColor -import br.com.stonks.feature.home.domain.types.PortfolioType +import br.com.stonks.feature.home.ui.model.PortfolioUiModel @Composable internal fun PortfolioCard( - portfolio: PortfolioModel, + uiModel: PortfolioUiModel, modifier: Modifier = Modifier, ) { OutlinedCard( @@ -60,12 +58,12 @@ internal fun PortfolioCard( TagLayout( modifier = Modifier .padding(end = SpacingToken.md), - containerColor = portfolio.portfolioType.getColor(), + containerColor = uiModel.tagColor, contentColor = ColorToken.NeutralWhite, - label = portfolio.allocation.formatPercent(), + label = uiModel.allocation.formatPercent(), ) Text( - text = portfolio.portfolioName, + text = uiModel.portfolioName, style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.ExtraBold, ), @@ -87,7 +85,7 @@ internal fun PortfolioCard( ) Text( modifier = Modifier.fillMaxWidth(), - text = portfolio.totalInvestment.formatCurrency(), + text = uiModel.totalInvestment.formatCurrency(), style = MaterialTheme.typography.titleSmall, color = ColorToken.Grayscale300, textAlign = TextAlign.End, @@ -116,9 +114,9 @@ private fun PortfolioLayoutPreview() { modifier = Modifier.padding(SpacingToken.xl) ) { PortfolioCard( - portfolio = PortfolioModel( + uiModel = PortfolioUiModel( + tagColor = ColorToken.HighlightGreen, portfolioName = "Ações", - portfolioType = PortfolioType.STOCK, totalInvestment = 1000.0, allocation = 0.3043f, ) diff --git a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/viewmodel/HomeViewModel.kt b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/viewmodel/HomeViewModel.kt index 4a846c8..5d148a9 100644 --- a/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/viewmodel/HomeViewModel.kt +++ b/feature/home/src/main/kotlin/br/com/stonks/feature/home/ui/viewmodel/HomeViewModel.kt @@ -1,49 +1,43 @@ package br.com.stonks.feature.home.ui.viewmodel import androidx.lifecycle.viewModelScope -import br.com.stonks.common.exception.StonksApiException import br.com.stonks.common.states.ViewModelState -import br.com.stonks.feature.home.domain.usecase.DailyTransactionUseCase -import br.com.stonks.feature.home.domain.usecase.WalletUseCase +import br.com.stonks.feature.home.domain.usecase.HomeContentUseCase +import br.com.stonks.feature.home.ui.mapper.HomeUiMapper import br.com.stonks.feature.home.ui.states.HomeUiEvent import br.com.stonks.feature.home.ui.states.HomeUiState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber + +internal val HOME_VM_QUALIFIER: String = HomeViewModel::class.java.simpleName internal class HomeViewModel( - private val walletUseCase: WalletUseCase, - private val dailyTransactionUseCase: DailyTransactionUseCase, + private val homeContentUseCase: HomeContentUseCase, + private val homeUiMapper: HomeUiMapper, ) : ViewModelState() { override val uiState: MutableStateFlow = MutableStateFlow(HomeUiState.Loading) - override fun dispatchUiEvent(uiEvent: HomeUiEvent) { - when (uiEvent) { - is HomeUiEvent.StartHome -> fetchHomeData() - } + init { + fetchHomeContent() } - private fun fetchHomeData() { + private fun fetchHomeContent() { viewModelScope.launch { - fetchWalletData() - fetchDailyTransactionData() + homeContentUseCase.fetchData().catch { + Timber.e(it, "Failure to fetch the home content") + uiState.value = HomeUiState.Error(it) + }.map { + homeUiMapper.mapper(it) + }.collectLatest { + uiState.value = HomeUiState.Success(it) + } } } - private suspend fun fetchWalletData() { - walletUseCase().catch { - uiState.value = HomeUiState.Error(it as StonksApiException) - }.collect { - uiState.value = HomeUiState.WalletSuccess(it) - } - } - - private suspend fun fetchDailyTransactionData() { - dailyTransactionUseCase().catch { - uiState.value = HomeUiState.Error(it as StonksApiException) - }.collect { - uiState.value = HomeUiState.DailyTransactionSuccess(it) - } - } + override fun dispatchUiEvent(uiEvent: HomeUiEvent) { } } diff --git a/infrastructure/network/src/main/kotlin/br/com/stonks/infrastructure/network/interceptor/MockRequestInterceptor.kt b/infrastructure/network/src/main/kotlin/br/com/stonks/infrastructure/network/interceptor/MockRequestInterceptor.kt index f4439b2..34f8096 100644 --- a/infrastructure/network/src/main/kotlin/br/com/stonks/infrastructure/network/interceptor/MockRequestInterceptor.kt +++ b/infrastructure/network/src/main/kotlin/br/com/stonks/infrastructure/network/interceptor/MockRequestInterceptor.kt @@ -10,14 +10,10 @@ internal class MockRequestInterceptor : Interceptor { } override fun intercept(chain: Interceptor.Chain): Response { - val httpUrl = chain.request().url - .newBuilder() - .addPathSegment(ASSET_EXTENSION) - .build() - + val mockUrl = chain.request().url.toString() + ASSET_EXTENSION val request = chain.request() .newBuilder() - .url(httpUrl) + .url(mockUrl) .build() return chain.proceed(request)