diff --git a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/component/ErrorStateTest.kt b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/component/ErrorStateTest.kt index 6dcb4bfa..8f7bdc2e 100644 --- a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/component/ErrorStateTest.kt +++ b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/component/ErrorStateTest.kt @@ -68,41 +68,4 @@ class ErrorStateTest { assertThat(onRetryCalled).isTrue() } } - - @Test - fun when_navigateUpProvided_should_displayBackButton() { - composeTestRule.setContent { - AppTheme { - ErrorState( - message = null, - onRetry = {}, - onNavigateUp = {} - ) - } - } - - composeTestRule.apply { - onNodeWithContentDescription("Back").assertIsDisplayed().assertHasClickAction() - } - } - - @Test - fun when_navigateUpClicked_should_callOnNavigateUp() { - var onNavigateUpCalled = false - - composeTestRule.setContent { - AppTheme { - ErrorState( - message = null, - onRetry = {}, - onNavigateUp = { onNavigateUpCalled = true } - ) - } - } - - composeTestRule.apply { - onNodeWithContentDescription("Back").performClick() - assertThat(onNavigateUpCalled).isTrue() - } - } } diff --git a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailScreenTest.kt b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailsScreenTest.kt similarity index 88% rename from app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailScreenTest.kt rename to app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailsScreenTest.kt index 32aaae65..7ff6afa4 100644 --- a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailScreenTest.kt +++ b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinDetailsScreenTest.kt @@ -11,35 +11,35 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeUp import com.google.common.truth.Truth.assertThat import dev.shorthouse.coinwatch.model.CoinChart -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.model.Percentage import dev.shorthouse.coinwatch.model.Price import dev.shorthouse.coinwatch.ui.model.ChartPeriod -import dev.shorthouse.coinwatch.ui.screen.detail.CoinDetailScreen -import dev.shorthouse.coinwatch.ui.screen.detail.CoinDetailUiState +import dev.shorthouse.coinwatch.ui.screen.details.CoinDetailsScreen +import dev.shorthouse.coinwatch.ui.screen.details.DetailsUiState import dev.shorthouse.coinwatch.ui.theme.AppTheme import java.math.BigDecimal import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test -class CoinDetailScreenTest { +class CoinDetailsScreenTest { @get:Rule val composeTestRule = createComposeRule() @Test fun when_uiStateLoading_should_showSkeletonLoader() { - val uiStateLoading = CoinDetailUiState.Loading + val uiStateLoading = DetailsUiState.Loading composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateLoading, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -54,16 +54,16 @@ class CoinDetailScreenTest { @Test fun when_uiStateError_should_showErrorState() { - val uiStateError = CoinDetailUiState.Error("Error message") + val uiStateError = DetailsUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateError, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -77,18 +77,18 @@ class CoinDetailScreenTest { } @Test - fun when_uiStateErrorRetryClicked_should_callOnErrorRetry() { - var onErrorRetryCalled = false - val uiStateError = CoinDetailUiState.Error("Error message") + fun when_uiStateErrorRetryClicked_should_callOnRefresh() { + var onRefreshCalled = false + val uiStateError = DetailsUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateError, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = { onErrorRetryCalled = true } + onRefresh = { onRefreshCalled = true } ) } } @@ -97,22 +97,22 @@ class CoinDetailScreenTest { onNodeWithText("Retry").performClick() } - assertThat(onErrorRetryCalled).isTrue() + assertThat(onRefreshCalled).isTrue() } @Test fun when_uiStateErrorBackClicked_should_callOnNavigateUp() { var onNavigateUpCalled = false - val uiStateError = CoinDetailUiState.Error("Error message") + val uiStateError = DetailsUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateError, onNavigateUp = { onNavigateUpCalled = true }, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -126,8 +126,8 @@ class CoinDetailScreenTest { @Test fun when_uiStateSuccess_should_showExpectedContent() { - val uiStateSuccess = CoinDetailUiState.Success( - CoinDetail( + val uiStateSuccess = DetailsUiState.Success( + CoinDetails( id = "ethereum", name = "Ethereum", symbol = "ETH", @@ -160,12 +160,12 @@ class CoinDetailScreenTest { composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateSuccess, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -194,7 +194,7 @@ class CoinDetailScreenTest { onNodeWithText("High").assertIsDisplayed() onNodeWithText("$1,922.83").assertIsDisplayed() - onNodeWithTag("coin_detail_content") + onNodeWithTag("coin_details_content") .performTouchInput { swipeUp(durationMillis = 500) } onNodeWithText("Market Stats").assertIsDisplayed() @@ -219,8 +219,8 @@ class CoinDetailScreenTest { fun when_backClicked_should_callOnNavigateUp() { var onNavigateUpCalled = false - val uiStateSuccess = CoinDetailUiState.Success( - CoinDetail( + val uiStateSuccess = DetailsUiState.Success( + CoinDetails( id = "ethereum", name = "Ethereum", symbol = "ETH", @@ -253,12 +253,12 @@ class CoinDetailScreenTest { composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateSuccess, onNavigateUp = { onNavigateUpCalled = true }, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -274,8 +274,8 @@ class CoinDetailScreenTest { fun when_favouriteCoinClicked_should_callOnClickFavouriteCoin() { var onClickFavouriteCoinCalled = false - val uiStateSuccess = CoinDetailUiState.Success( - CoinDetail( + val uiStateSuccess = DetailsUiState.Success( + CoinDetails( id = "ethereum", name = "Ethereum", symbol = "ETH", @@ -308,12 +308,12 @@ class CoinDetailScreenTest { composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateSuccess, onNavigateUp = {}, onClickFavouriteCoin = { onClickFavouriteCoinCalled = true }, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -331,8 +331,8 @@ class CoinDetailScreenTest { .associateWith { false } .toMutableMap() - val uiStateSuccess = CoinDetailUiState.Success( - CoinDetail( + val uiStateSuccess = DetailsUiState.Success( + CoinDetails( id = "ethereum", name = "Ethereum", symbol = "ETH", @@ -365,12 +365,12 @@ class CoinDetailScreenTest { composeTestRule.setContent { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiStateSuccess, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = { onClickChartPeriodMap[it] = true }, - onErrorRetry = {} + onRefresh = {} ) } } diff --git a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinListScreenTest.kt b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinListScreenTest.kt index b9e8b189..97d87c45 100644 --- a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinListScreenTest.kt +++ b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinListScreenTest.kt @@ -11,9 +11,8 @@ import com.google.common.truth.Truth.assertThat import dev.shorthouse.coinwatch.model.Coin import dev.shorthouse.coinwatch.model.Percentage import dev.shorthouse.coinwatch.model.Price -import dev.shorthouse.coinwatch.ui.model.TimeOfDay -import dev.shorthouse.coinwatch.ui.screen.list.CoinListScreen -import dev.shorthouse.coinwatch.ui.screen.list.CoinListUiState +import dev.shorthouse.coinwatch.ui.screen.list.ListScreen +import dev.shorthouse.coinwatch.ui.screen.list.ListUiState import dev.shorthouse.coinwatch.ui.theme.AppTheme import java.math.BigDecimal import kotlinx.collections.immutable.persistentListOf @@ -27,15 +26,14 @@ class CoinListScreenTest { @Test fun when_uiStateLoading_should_showSkeletonLoader() { - val uiStateLoading = CoinListUiState.Loading + val uiStateLoading = ListUiState.Loading composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateLoading, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -48,15 +46,14 @@ class CoinListScreenTest { @Test fun when_uiStateError_should_showErrorState() { - val uiStateError = CoinListUiState.Error("Error message") + val uiStateError = ListUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateError, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -70,17 +67,16 @@ class CoinListScreenTest { } @Test - fun when_uiStateErrorRetryClicked_should_callOnErrorRetry() { - var onErrorRetryCalled = false - val uiStateError = CoinListUiState.Error("Error message") + fun when_uiStateErrorRetryClicked_should_callOnRefresh() { + var onRefreshCalled = false + val uiStateError = ListUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateError, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = { onErrorRetryCalled = true } + onRefresh = { onRefreshCalled = true } ) } } @@ -89,24 +85,21 @@ class CoinListScreenTest { onNodeWithText("Retry").performClick() } - assertThat(onErrorRetryCalled).isTrue() + assertThat(onRefreshCalled).isTrue() } @Test fun when_uiStateSuccess_should_showExpectedContent() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -119,19 +112,16 @@ class CoinListScreenTest { @Test fun when_uiStateSuccess_favouriteCoinsEmpty_should_showEmptyState() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -146,62 +136,16 @@ class CoinListScreenTest { @Test fun when_uiStateSuccess_favouriteCoinsList_should_showExpectedContent() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf( - Coin( - id = "bitcoin", - symbol = "BTC", - name = "Bitcoin", - imageUrl = "https://cdn.coinranking.com/bOabBYkcX/bitcoin_btc.svg", - currentPrice = Price("29446.336548759988"), - priceChangePercentage24h = Percentage("0.76833"), - prices24h = persistentListOf( - BigDecimal("29390.15178296929"), - BigDecimal("29428.222505493162"), - BigDecimal("29475.12359313808"), - BigDecimal("29471.20179209623") - ) - ), - Coin( - id = "ethereum", - symbol = "ETH", - name = "Ethereum", - imageUrl = "https://cdn.coinranking.com/rk4RKHOuW/eth.svg", - currentPrice = Price("1875.473083380222"), - priceChangePercentage24h = Percentage("-1.11008"), - prices24h = persistentListOf( - BigDecimal("1854.8824120105778"), - BigDecimal("1853.3272421902477"), - BigDecimal("1857.8290158859397"), - BigDecimal("1859.4549720388395") - ) - ), - Coin( - id = "tether", - symbol = "USDT", - name = "Tether", - imageUrl = "https://cdn.coinranking.com/mgHqwlCLj/usdt.svg", - currentPrice = Price("1.00"), - priceChangePercentage24h = Percentage("0.00"), - prices24h = persistentListOf( - BigDecimal("1.00"), - BigDecimal("1.00"), - BigDecimal("1.00"), - BigDecimal("1.00") - ) - ) - ), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -232,19 +176,16 @@ class CoinListScreenTest { @Test fun when_uiStateSuccess_coinsEmpty_should_showEmptyState() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -257,7 +198,7 @@ class CoinListScreenTest { @Test fun when_uiStateSuccess_coinsList_should_showExpectedContent() { - val uiStateSuccess = CoinListUiState.Success( + val uiStateSuccess = ListUiState.Success( coins = persistentListOf( Coin( id = "bitcoin", @@ -301,18 +242,15 @@ class CoinListScreenTest { BigDecimal("1.00") ) ) - ), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + ) ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -342,7 +280,7 @@ class CoinListScreenTest { fun when_coinItemClicked_should_callOnClick() { var onCoinClickCalled = false - val uiStateSuccess = CoinListUiState.Success( + val uiStateSuccess = ListUiState.Success( coins = persistentListOf( Coin( id = "bitcoin", @@ -358,18 +296,15 @@ class CoinListScreenTest { BigDecimal("29471.20179209623") ) ) - ), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + ) ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = ({ onCoinClickCalled = true }), - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -385,34 +320,16 @@ class CoinListScreenTest { fun when_favouriteCoinItemClicked_should_callOnClick() { var onCoinClickCalled = false - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf( - Coin( - id = "bitcoin", - symbol = "BTC", - name = "Bitcoin", - imageUrl = "https://cdn.coinranking.com/bOabBYkcX/bitcoin_btc.svg", - currentPrice = Price("29446.336548759988"), - priceChangePercentage24h = Percentage("0.76833"), - prices24h = persistentListOf( - BigDecimal("29390.15178296929"), - BigDecimal("29428.222505493162"), - BigDecimal("29475.12359313808"), - BigDecimal("29471.20179209623") - ) - ) - ), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = ({ onCoinClickCalled = true }), - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -426,19 +343,16 @@ class CoinListScreenTest { @Test fun when_timeOfDayMorning_should_showMorningGreeting() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -450,19 +364,16 @@ class CoinListScreenTest { @Test fun when_timeOfDayAfternoon_should_showAfternoonGreeting() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Afternoon + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -474,19 +385,16 @@ class CoinListScreenTest { @Test fun when_timeOfDayEvening_should_showEveningGreeting() { - val uiStateSuccess = CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Evening + val uiStateSuccess = ListUiState.Success( + coins = persistentListOf() ) composeTestRule.setContent { AppTheme { - CoinListScreen( + ListScreen( uiState = uiStateSuccess, onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} + onRefresh = {} ) } } diff --git a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinSearchScreenTest.kt b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinSearchScreenTest.kt index a34f1c00..dccf59ef 100644 --- a/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinSearchScreenTest.kt +++ b/app/src/androidTest/java/dev/shorthouse/coinwatch/ui/screen/CoinSearchScreenTest.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.google.common.truth.Truth.assertThat import dev.shorthouse.coinwatch.model.SearchCoin -import dev.shorthouse.coinwatch.ui.screen.search.CoinSearchScreen -import dev.shorthouse.coinwatch.ui.screen.search.CoinSearchUiState +import dev.shorthouse.coinwatch.ui.screen.search.SearchScreen +import dev.shorthouse.coinwatch.ui.screen.search.SearchUiState import dev.shorthouse.coinwatch.ui.theme.AppTheme import kotlinx.collections.immutable.persistentListOf import org.junit.Rule @@ -24,17 +24,16 @@ class CoinSearchScreenTest { @Test fun when_uiStateLoading_should_showSkeletonLoader() { - val uiStateError = CoinSearchUiState.Error("Error message") + val uiStateError = SearchUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateError, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -48,19 +47,18 @@ class CoinSearchScreenTest { } @Test - fun when_uiStateErrorRetryClicked_should_callOnErrorRetry() { - var onErrorRetryCalled = false - val uiStateError = CoinSearchUiState.Error("Error message") + fun when_uiStateErrorRetryClicked_should_callOnRefresh() { + var onRefreshCalled = false + val uiStateError = SearchUiState.Error("Error message") composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateError, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = { onErrorRetryCalled = true } + onRefresh = { onRefreshCalled = true } ) } } @@ -69,50 +67,24 @@ class CoinSearchScreenTest { onNodeWithText("Retry").performClick() } - assertThat(onErrorRetryCalled).isTrue() - } - - @Test - fun when_uiStateErrorBackClicked_should_callOnNavigateUp() { - var onNavigateUpCalled = false - val uiStateError = CoinSearchUiState.Error("Error message") - - composeTestRule.setContent { - AppTheme { - CoinSearchScreen( - uiState = uiStateError, - searchQuery = "", - onSearchQueryChange = {}, - onNavigateUp = { onNavigateUpCalled = true }, - onCoinClick = {}, - onErrorRetry = {} - ) - } - } - - composeTestRule.apply { - onNodeWithContentDescription("Back").performClick() - } - - assertThat(onNavigateUpCalled).isTrue() + assertThat(onRefreshCalled).isTrue() } @Test fun when_uiStateSuccess_should_showExpectedContent() { - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -123,53 +95,23 @@ class CoinSearchScreenTest { } } - @Test - fun when_backClicked_should_callOnNavigateUp() { - var onNavigateUpCalled = false - - val uiStateSuccess = CoinSearchUiState.Success( - searchResults = persistentListOf(), - queryHasNoResults = false - ) - - composeTestRule.setContent { - AppTheme { - CoinSearchScreen( - uiState = uiStateSuccess, - searchQuery = "", - onSearchQueryChange = {}, - onNavigateUp = { onNavigateUpCalled = true }, - onCoinClick = {}, - onErrorRetry = {} - ) - } - } - - composeTestRule.apply { - onNodeWithContentDescription("Back").performClick() - } - - assertThat(onNavigateUpCalled).isTrue() - } - @Test fun when_searchQueryEntered_should_displaySearchQuery() { val searchQuery = "Bitcoin" - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = searchQuery, onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -183,20 +125,19 @@ class CoinSearchScreenTest { fun when_searchQueryEntered_should_displayClearSearchButton() { val searchQuery = "Bitcoin" - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = searchQuery, onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -210,20 +151,19 @@ class CoinSearchScreenTest { fun when_clearSearchClicked_should_clearSearchQuery() { val searchQuery = mutableStateOf("Bitcoin") - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = searchQuery.value, onSearchQueryChange = { searchQuery.value = it }, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -239,20 +179,19 @@ class CoinSearchScreenTest { fun when_typingInSearchBar_should_updateSearchQuery() { val searchQuery = mutableStateOf("") - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = searchQuery.value, onSearchQueryChange = { searchQuery.value = it }, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -282,20 +221,19 @@ class CoinSearchScreenTest { ) ) - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = searchResults, queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } @@ -321,20 +259,19 @@ class CoinSearchScreenTest { ) ) - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = searchResults, queryHasNoResults = false ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = { onCoinClickCalled = true }, - onErrorRetry = {} + onRefresh = {} ) } } @@ -348,20 +285,19 @@ class CoinSearchScreenTest { @Test fun when_searchQueryHasNoResults_should_displaySearchEmptyState() { - val uiStateSuccess = CoinSearchUiState.Success( + val uiStateSuccess = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = true ) composeTestRule.setContent { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiStateSuccess, searchQuery = "abcdefghijk", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapper.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapper.kt similarity index 53% rename from app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapper.kt rename to app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapper.kt index b6a34312..040771d2 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapper.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapper.kt @@ -1,8 +1,8 @@ package dev.shorthouse.coinwatch.data.mapper import dev.shorthouse.coinwatch.common.Mapper -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.model.Price import java.text.NumberFormat import java.time.DateTimeException @@ -12,7 +12,7 @@ import java.time.format.DateTimeFormatter import java.util.Locale import javax.inject.Inject -class CoinDetailMapper @Inject constructor() : Mapper { +class CoinDetailsMapper @Inject constructor() : Mapper { companion object { private val dateFormatter = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.US) @@ -21,22 +21,22 @@ class CoinDetailMapper @Inject constructor() : Mapper> -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/repository/detail/CoinDetailRepositoryImpl.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/repository/detail/CoinDetailRepositoryImpl.kt deleted file mode 100644 index 40675bd3..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/repository/detail/CoinDetailRepositoryImpl.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.shorthouse.coinwatch.data.repository.detail - -import dev.shorthouse.coinwatch.common.Result -import dev.shorthouse.coinwatch.data.mapper.CoinDetailMapper -import dev.shorthouse.coinwatch.data.source.remote.CoinNetworkDataSourceImpl -import dev.shorthouse.coinwatch.di.IoDispatcher -import dev.shorthouse.coinwatch.model.CoinDetail -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import timber.log.Timber - -class CoinDetailRepositoryImpl @Inject constructor( - private val coinNetworkDataSource: CoinNetworkDataSourceImpl, - private val coinDetailMapper: CoinDetailMapper, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher -) : CoinDetailRepository { - override fun getCoinDetail(coinId: String): Flow> = flow { - val response = coinNetworkDataSource.getCoinDetail(coinId = coinId) - val body = response.body() - - if (response.isSuccessful && body?.coinDetailDataHolder?.coinDetailData != null) { - val coinDetail = coinDetailMapper.mapApiModelToModel(body) - emit(Result.Success(coinDetail)) - } else { - Timber.e("getCoinDetail unsuccessful retrofit response ${response.message()}") - emit(Result.Error("Unable to fetch coin details")) - } - }.catch { e -> - Timber.e("getCoinDetail exception ${e.message}") - emit(Result.Error("Unable to fetch coin details")) - }.flowOn(ioDispatcher) -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepository.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepository.kt new file mode 100644 index 00000000..3975ff42 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepository.kt @@ -0,0 +1,9 @@ +package dev.shorthouse.coinwatch.data.repository.details + +import dev.shorthouse.coinwatch.common.Result +import dev.shorthouse.coinwatch.model.CoinDetails +import kotlinx.coroutines.flow.Flow + +interface CoinDetailsRepository { + fun getCoinDetails(coinId: String): Flow> +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepositoryImpl.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepositoryImpl.kt new file mode 100644 index 00000000..74ab9e59 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/repository/details/CoinDetailsRepositoryImpl.kt @@ -0,0 +1,36 @@ +package dev.shorthouse.coinwatch.data.repository.details + +import dev.shorthouse.coinwatch.common.Result +import dev.shorthouse.coinwatch.data.mapper.CoinDetailsMapper +import dev.shorthouse.coinwatch.data.source.remote.CoinNetworkDataSourceImpl +import dev.shorthouse.coinwatch.di.IoDispatcher +import dev.shorthouse.coinwatch.model.CoinDetails +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import timber.log.Timber + +class CoinDetailsRepositoryImpl @Inject constructor( + private val coinNetworkDataSource: CoinNetworkDataSourceImpl, + private val coinDetailsMapper: CoinDetailsMapper, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher +) : CoinDetailsRepository { + override fun getCoinDetails(coinId: String): Flow> = flow { + val response = coinNetworkDataSource.getCoinDetails(coinId = coinId) + val body = response.body() + + if (response.isSuccessful && body?.coinDetailsDataHolder?.coinDetailsData != null) { + val coinDetails = coinDetailsMapper.mapApiModelToModel(body) + emit(Result.Success(coinDetails)) + } else { + Timber.e("getCoinDetails unsuccessful retrofit response ${response.message()}") + emit(Result.Error("Unable to fetch coin details")) + } + }.catch { e -> + Timber.e("getCoinDetails exception ${e.message}") + emit(Result.Error("Unable to fetch coin details")) + }.flowOn(ioDispatcher) +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinApi.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinApi.kt index 8790e0c1..ed4029a2 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinApi.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinApi.kt @@ -1,7 +1,7 @@ package dev.shorthouse.coinwatch.data.source.remote import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinsApiModel import retrofit2.Response @@ -21,10 +21,10 @@ interface CoinApi { ): Response @GET("coin/{coinId}") - suspend fun getCoinDetail( + suspend fun getCoinDetails( @Path("coinId") coinId: String, @Query("referenceCurrencyUuid") currencyUUID: String = "yhjMzLPhuIDl" - ): Response + ): Response @GET("coin/{coinId}/history") suspend fun getCoinChart( diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSource.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSource.kt index 9da75a41..60d87f0f 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSource.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSource.kt @@ -1,7 +1,7 @@ package dev.shorthouse.coinwatch.data.source.remote import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinsApiModel import retrofit2.Response @@ -9,7 +9,7 @@ import retrofit2.Response interface CoinNetworkDataSource { suspend fun getCoins(coinIds: List): Response - suspend fun getCoinDetail(coinId: String): Response + suspend fun getCoinDetails(coinId: String): Response suspend fun getCoinChart( coinId: String, diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSourceImpl.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSourceImpl.kt index 7753e2a7..d12b3c56 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSourceImpl.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/CoinNetworkDataSourceImpl.kt @@ -1,7 +1,7 @@ package dev.shorthouse.coinwatch.data.source.remote import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinsApiModel import javax.inject.Inject @@ -13,8 +13,8 @@ class CoinNetworkDataSourceImpl @Inject constructor(private val coinApi: CoinApi return coinApi.getCoins(coinIds = coinIds) } - override suspend fun getCoinDetail(coinId: String): Response { - return coinApi.getCoinDetail(coinId = coinId) + override suspend fun getCoinDetails(coinId: String): Response { + return coinApi.getCoinDetails(coinId = coinId) } override suspend fun getCoinChart( diff --git a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailApiModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailsApiModel.kt similarity index 84% rename from app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailApiModel.kt rename to app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailsApiModel.kt index 5a2b962a..50483136 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailApiModel.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/data/source/remote/model/CoinDetailsApiModel.kt @@ -2,17 +2,17 @@ package dev.shorthouse.coinwatch.data.source.remote.model import com.google.gson.annotations.SerializedName -data class CoinDetailApiModel( +data class CoinDetailsApiModel( @SerializedName("data") - val coinDetailDataHolder: CoinDetailDataHolder? + val coinDetailsDataHolder: CoinDetailsDataHolder? ) -data class CoinDetailDataHolder( +data class CoinDetailsDataHolder( @SerializedName("coin") - val coinDetailData: CoinDetailData? + val coinDetailsData: CoinDetailsData? ) -data class CoinDetailData( +data class CoinDetailsData( @SerializedName("uuid") val id: String?, @SerializedName("name") diff --git a/app/src/main/java/dev/shorthouse/coinwatch/di/MapperModule.kt b/app/src/main/java/dev/shorthouse/coinwatch/di/MapperModule.kt index 3d9b647f..94d31d09 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/di/MapperModule.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/di/MapperModule.kt @@ -5,7 +5,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dev.shorthouse.coinwatch.data.mapper.CoinChartMapper -import dev.shorthouse.coinwatch.data.mapper.CoinDetailMapper +import dev.shorthouse.coinwatch.data.mapper.CoinDetailsMapper import dev.shorthouse.coinwatch.data.mapper.CoinMapper import dev.shorthouse.coinwatch.data.mapper.CoinSearchResultsMapper import javax.inject.Singleton @@ -28,8 +28,8 @@ object MapperModule { @Provides @Singleton - fun provideCoinDetailMapper(): CoinDetailMapper { - return CoinDetailMapper() + fun provideCoinDetailsMapper(): CoinDetailsMapper { + return CoinDetailsMapper() } @Provides diff --git a/app/src/main/java/dev/shorthouse/coinwatch/di/NetworkDataModule.kt b/app/src/main/java/dev/shorthouse/coinwatch/di/NetworkDataModule.kt index f68be21c..0ce5a880 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/di/NetworkDataModule.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/di/NetworkDataModule.kt @@ -5,15 +5,15 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dev.shorthouse.coinwatch.data.mapper.CoinChartMapper -import dev.shorthouse.coinwatch.data.mapper.CoinDetailMapper +import dev.shorthouse.coinwatch.data.mapper.CoinDetailsMapper import dev.shorthouse.coinwatch.data.mapper.CoinMapper import dev.shorthouse.coinwatch.data.mapper.CoinSearchResultsMapper import dev.shorthouse.coinwatch.data.repository.chart.CoinChartRepository import dev.shorthouse.coinwatch.data.repository.chart.CoinChartRepositoryImpl import dev.shorthouse.coinwatch.data.repository.coin.CoinRepository import dev.shorthouse.coinwatch.data.repository.coin.CoinRepositoryImpl -import dev.shorthouse.coinwatch.data.repository.detail.CoinDetailRepository -import dev.shorthouse.coinwatch.data.repository.detail.CoinDetailRepositoryImpl +import dev.shorthouse.coinwatch.data.repository.details.CoinDetailsRepository +import dev.shorthouse.coinwatch.data.repository.details.CoinDetailsRepositoryImpl import dev.shorthouse.coinwatch.data.repository.searchResults.CoinSearchResultsRepository import dev.shorthouse.coinwatch.data.repository.searchResults.CoinSearchResultsRepositoryImpl import dev.shorthouse.coinwatch.data.source.remote.CoinApi @@ -41,14 +41,14 @@ object NetworkDataModule { @Provides @Singleton - fun provideCoinDetailRepository( + fun provideCoinDetailsRepository( coinNetworkDataSource: CoinNetworkDataSourceImpl, - coinDetailMapper: CoinDetailMapper, + coinDetailsMapper: CoinDetailsMapper, @IoDispatcher ioDispatcher: CoroutineDispatcher - ): CoinDetailRepository { - return CoinDetailRepositoryImpl( + ): CoinDetailsRepository { + return CoinDetailsRepositoryImpl( coinNetworkDataSource = coinNetworkDataSource, - coinDetailMapper = coinDetailMapper, + coinDetailsMapper = coinDetailsMapper, ioDispatcher = ioDispatcher ) } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailUseCase.kt b/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailUseCase.kt deleted file mode 100644 index 315a0352..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.shorthouse.coinwatch.domain - -import dev.shorthouse.coinwatch.common.Result -import dev.shorthouse.coinwatch.data.repository.detail.CoinDetailRepository -import dev.shorthouse.coinwatch.model.CoinDetail -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow - -class GetCoinDetailUseCase @Inject constructor( - private val coinDetailRepository: CoinDetailRepository -) { - operator fun invoke(coinId: String): Flow> { - return getCoinDetail(coinId = coinId) - } - - private fun getCoinDetail(coinId: String): Flow> { - return coinDetailRepository.getCoinDetail(coinId = coinId) - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailsUseCase.kt b/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailsUseCase.kt new file mode 100644 index 00000000..ff9cabd1 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/domain/GetCoinDetailsUseCase.kt @@ -0,0 +1,19 @@ +package dev.shorthouse.coinwatch.domain + +import dev.shorthouse.coinwatch.common.Result +import dev.shorthouse.coinwatch.data.repository.details.CoinDetailsRepository +import dev.shorthouse.coinwatch.model.CoinDetails +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class GetCoinDetailsUseCase @Inject constructor( + private val coinDetailsRepository: CoinDetailsRepository +) { + operator fun invoke(coinId: String): Flow> { + return getCoinDetails(coinId = coinId) + } + + private fun getCoinDetails(coinId: String): Flow> { + return coinDetailsRepository.getCoinDetails(coinId = coinId) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetail.kt b/app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetails.kt similarity index 93% rename from app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetail.kt rename to app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetails.kt index 4a80e089..ee33fef1 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetail.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/model/CoinDetails.kt @@ -1,6 +1,6 @@ package dev.shorthouse.coinwatch.model -data class CoinDetail( +data class CoinDetails( val id: String, val name: String, val symbol: String, diff --git a/app/src/main/java/dev/shorthouse/coinwatch/navigation/AppNavHost.kt b/app/src/main/java/dev/shorthouse/coinwatch/navigation/AppNavHost.kt index c5f0df03..490d6c86 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/navigation/AppNavHost.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/navigation/AppNavHost.kt @@ -1,34 +1,27 @@ package dev.shorthouse.coinwatch.navigation import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import dev.shorthouse.coinwatch.ui.screen.detail.CoinDetailScreen -import dev.shorthouse.coinwatch.ui.screen.list.CoinListScreen -import dev.shorthouse.coinwatch.ui.screen.search.CoinSearchScreen +import dev.shorthouse.coinwatch.ui.screen.details.CoinDetailsScreen @Composable -fun AppNavHost( - modifier: Modifier = Modifier, - navController: NavHostController = rememberNavController(), - startDestination: String = Screen.CoinList.route -) { +fun AppNavHost(navController: NavHostController = rememberNavController()) { + val onNavigateDetails: (String) -> Unit = { coinId -> + navController.navigate(Screen.Details.route + "/$coinId") + } + NavHost( navController = navController, - startDestination = startDestination, - modifier = modifier + startDestination = Screen.NavigationBar.route ) { - composable(route = Screen.CoinList.route) { - CoinListScreen(navController = navController) - } - composable(route = Screen.CoinDetail.route + "/{coinId}") { - CoinDetailScreen(navController = navController) + composable(Screen.NavigationBar.route) { + NavigationBarScaffold(onNavigateDetails = onNavigateDetails) } - composable(route = Screen.CoinSearch.route) { - CoinSearchScreen(navController = navController) + composable(route = Screen.Details.route + "/{coinId}") { + CoinDetailsScreen(onNavigateUp = { navController.navigateUp() }) } } } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/navigation/NavigationBarScaffold.kt b/app/src/main/java/dev/shorthouse/coinwatch/navigation/NavigationBarScaffold.kt new file mode 100644 index 00000000..7714cbf0 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/navigation/NavigationBarScaffold.kt @@ -0,0 +1,146 @@ +package dev.shorthouse.coinwatch.navigation + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import dev.shorthouse.coinwatch.ui.screen.favourites.FavouritesScreen +import dev.shorthouse.coinwatch.ui.screen.list.ListScreen +import dev.shorthouse.coinwatch.ui.screen.search.SearchScreen +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun NavigationBarScaffold( + onNavigateDetails: (String) -> Unit, + modifier: Modifier = Modifier +) { + val navController = rememberNavController() + + val navigationBarScreens = remember { + persistentListOf( + NavigationBarScreen.Market, + NavigationBarScreen.Favourites, + NavigationBarScreen.Search + ) + } + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val showNavigationBar = navigationBarScreens.any { it.route == currentDestination?.route } + + Scaffold( + bottomBar = { + if (showNavigationBar) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + tonalElevation = 0.dp + ) { + navigationBarScreens.forEach { screen -> + AddNavigationBarItem( + screen = screen, + currentDestination = currentDestination, + navController = navController + ) + } + } + } + }, + content = { scaffoldPadding -> + NavigationBarNavHost( + navController = navController, + onNavigateDetails = onNavigateDetails, + modifier = Modifier.padding(scaffoldPadding) + ) + }, + modifier = modifier + ) +} + +@Composable +private fun NavigationBarNavHost( + navController: NavHostController, + modifier: Modifier = Modifier, + onNavigateDetails: (String) -> Unit +) { + NavHost( + navController = navController, + startDestination = NavigationBarScreen.Market.route, + modifier = modifier + ) { + composable(route = NavigationBarScreen.Market.route) { + ListScreen(onNavigateDetails = onNavigateDetails) + } + composable(route = NavigationBarScreen.Favourites.route) { + FavouritesScreen(onNavigateDetails = onNavigateDetails) + } + composable(route = NavigationBarScreen.Search.route) { + SearchScreen(onNavigateDetails = onNavigateDetails) + } + } +} + +@Composable +private fun RowScope.AddNavigationBarItem( + screen: NavigationBarScreen, + currentDestination: NavDestination?, + navController: NavHostController, + modifier: Modifier = Modifier +) { + val selected = currentDestination?.hierarchy?.any { destination -> + destination.route == screen.route + } == true + + NavigationBarItem( + label = { + Text( + text = stringResource(screen.nameResourceId), + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + }, + icon = { + Icon( + imageVector = screen.icon, + contentDescription = null + ) + }, + selected = selected, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSurface, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.background, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = modifier + ) +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/navigation/Screen.kt b/app/src/main/java/dev/shorthouse/coinwatch/navigation/Screen.kt index ef8d7d53..a934257f 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/navigation/Screen.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/navigation/Screen.kt @@ -1,7 +1,38 @@ package dev.shorthouse.coinwatch.navigation +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BarChart +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Search +import androidx.compose.ui.graphics.vector.ImageVector +import dev.shorthouse.coinwatch.R + +sealed class NavigationBarScreen( + val route: String, + @StringRes val nameResourceId: Int, + val icon: ImageVector +) { + object Market : NavigationBarScreen( + route = "market_screen", + nameResourceId = R.string.market_screen, + icon = Icons.Rounded.BarChart + ) + + object Favourites : NavigationBarScreen( + route = "favourites_screen", + nameResourceId = R.string.favourites_screen, + icon = Icons.Rounded.Favorite + ) + + object Search : NavigationBarScreen( + route = "search_screen", + nameResourceId = R.string.search_screen, + icon = Icons.Rounded.Search + ) +} + sealed class Screen(val route: String) { - object CoinList : Screen("coin_list_screen") - object CoinDetail : Screen("coin_detail_screen") - object CoinSearch : Screen("coin_search_screen") + object Details : Screen("details_screen") + object NavigationBar : Screen("navigation_bar_screen") } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinsEmptyState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/component/EmptyState.kt similarity index 56% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinsEmptyState.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/component/EmptyState.kt index 52f057da..30e876c0 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinsEmptyState.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/component/EmptyState.kt @@ -1,7 +1,9 @@ -package dev.shorthouse.coinwatch.ui.screen.list.component +package dev.shorthouse.coinwatch.ui.component import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -9,53 +11,67 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable -fun CoinsEmptyState(modifier: Modifier = Modifier) { - Surface(modifier = modifier.fillMaxSize()) { +fun EmptyState( + image: Painter, + title: String, + subtitle: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background) + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(12.dp) + modifier = Modifier + .fillMaxSize() + .padding(12.dp) ) { Image( - painter = painterResource(R.drawable.empty_state_coins), + painter = image, contentDescription = null, - modifier = Modifier.size(180.dp) + modifier = Modifier.size(250.dp) ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) Text( - text = stringResource(R.string.empty_state_coins_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground ) + Spacer(Modifier.height(4.dp)) + + subtitle() + } + } +} + +@Composable +@Preview +private fun EmptyStatePreview() { + EmptyState( + image = painterResource(R.drawable.empty_state_coins), + title = "No coins", + subtitle = { Text( text = stringResource(R.string.empty_state_coins_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } -} - -@Preview -@Composable -private fun CoinsEmptyStatePreview() { - AppTheme { - CoinsEmptyState() - } + ) } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/component/ErrorState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/component/ErrorState.kt index 5c49e2bb..432888f2 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/component/ErrorState.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/component/ErrorState.kt @@ -3,25 +3,16 @@ package dev.shorthouse.coinwatch.ui.component import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,95 +23,65 @@ import androidx.compose.ui.unit.dp import dev.shorthouse.coinwatch.R import dev.shorthouse.coinwatch.ui.theme.AppTheme -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ErrorState( message: String?, onRetry: () -> Unit, - modifier: Modifier = Modifier, - onNavigateUp: (() -> Unit)? = null + modifier: Modifier = Modifier ) { - Scaffold( - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - onNavigateUp?.let { - IconButton(onClick = onNavigateUp) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = stringResource(R.string.cd_top_bar_back) - ) - } - } - }, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) - }, - content = { scaffoldPadding -> - Box( - modifier = modifier - .padding(scaffoldPadding) - .background(MaterialTheme.colorScheme.background) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - ) { - Image( - painter = painterResource(R.drawable.error_state), - contentDescription = stringResource(R.string.cd_error_state), - modifier = Modifier.size(250.dp) - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxSize() + .padding(12.dp) + .background(MaterialTheme.colorScheme.background) + ) { + Image( + painter = painterResource(R.drawable.error_state), + contentDescription = stringResource(R.string.cd_error_state), + modifier = Modifier.size(250.dp) + ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(R.string.error_occurred), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground - ) + Text( + text = stringResource(R.string.error_occurred), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(4.dp)) - message?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + message?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - Button( - onClick = onRetry, - shape = MaterialTheme.shapes.small, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Text( - text = stringResource(R.string.button_retry), - style = MaterialTheme.typography.titleSmall - ) - } - } - } + Button( + onClick = onRetry, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = stringResource(R.string.button_retry), + style = MaterialTheme.typography.titleSmall + ) } - ) + } } @Composable @Preview -fun ErrorStatePreview() { +private fun ErrorStatePreview() { AppTheme { ErrorState( message = "No internet connection", diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinDetailUiStatePreviewProvider.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinDetailUiStatePreviewProvider.kt deleted file mode 100644 index 21dc6331..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinDetailUiStatePreviewProvider.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.shorthouse.coinwatch.ui.previewdata - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import dev.shorthouse.coinwatch.model.CoinChart -import dev.shorthouse.coinwatch.model.CoinDetail -import dev.shorthouse.coinwatch.model.Percentage -import dev.shorthouse.coinwatch.model.Price -import dev.shorthouse.coinwatch.ui.model.ChartPeriod -import dev.shorthouse.coinwatch.ui.screen.detail.CoinDetailUiState -import java.math.BigDecimal -import kotlinx.collections.immutable.persistentListOf - -class CoinDetailUiStatePreviewProvider : PreviewParameterProvider { - override val values = sequenceOf( - CoinDetailUiState.Success( - CoinDetail( - id = "ethereum", - name = "Ethereum", - symbol = "ETH", - imageUrl = "https://cdn.coinranking.com/rk4RKHOuW/eth.svg", - currentPrice = Price("1879.14"), - marketCap = Price("225722901094"), - marketCapRank = "2", - volume24h = "6,627,669,115", - circulatingSupply = "120,186,525", - allTimeHigh = Price("4878.26"), - allTimeHighDate = "10 Nov 2021", - listedDate = "7 Aug 2015" - ), - CoinChart( - prices = persistentListOf( - BigDecimal("1755.19"), BigDecimal("1749.71"), BigDecimal("1750.94"), BigDecimal("1748.44"), BigDecimal("1743.98"), BigDecimal("1740.25"), BigDecimal("1737.53"), BigDecimal("1730.56"), BigDecimal("1738.12"), BigDecimal("1736.10"), BigDecimal("1740.20"), BigDecimal("1740.64"), BigDecimal("1741.49"), BigDecimal("1738.87"), BigDecimal("1734.92"), BigDecimal("1736.79"), BigDecimal("1743.53"), BigDecimal("1743.21"), BigDecimal("1744.75"), BigDecimal("1744.85"), BigDecimal("1741.76"), BigDecimal("1741.46"), BigDecimal("1739.82"), BigDecimal("1740.15"), BigDecimal("1745.08"), BigDecimal("1743.29"), BigDecimal("1746.12"), BigDecimal("1745.99"), BigDecimal("1744.89"), BigDecimal("1741.10"), BigDecimal("1741.91"), BigDecimal("1738.47"), BigDecimal("1737.67"), BigDecimal("1741.82"), BigDecimal("1735.95"), BigDecimal("1728.11"), BigDecimal("1657.23"), BigDecimal("1649.89"), BigDecimal("1649.71"), BigDecimal("1650.68"), BigDecimal("1654.04"), BigDecimal("1648.55"), BigDecimal("1650.10"), BigDecimal("1651.87"), BigDecimal("1651.29"), BigDecimal("1642.75"), BigDecimal("1637.79"), BigDecimal("1635.80"), BigDecimal("1637.01"), BigDecimal("1632.46"), BigDecimal("1633.31"), BigDecimal("1640.08"), BigDecimal("1638.61"), BigDecimal("1645.47"), BigDecimal("1643.50"), BigDecimal("1640.57"), BigDecimal("1640.41"), BigDecimal("1641.38"), BigDecimal("1660.21"), BigDecimal("1665.73"), BigDecimal("1660.33"), BigDecimal("1665.65"), BigDecimal("1664.11"), BigDecimal("1665.71"), BigDecimal("1661.90"), BigDecimal("1661.17"), BigDecimal("1662.54"), BigDecimal("1665.58"), BigDecimal("1666.27"), BigDecimal("1669.82"), BigDecimal("1671.34"), BigDecimal("1669.87"), BigDecimal("1670.62"), BigDecimal("1668.97"), BigDecimal("1668.86"), BigDecimal("1664.58"), BigDecimal("1665.96"), BigDecimal("1664.53"), BigDecimal("1656.15"), BigDecimal("1670.91"), BigDecimal("1685.59"), BigDecimal("1693.69"), BigDecimal("1718.10"), BigDecimal("1719.56"), BigDecimal("1724.42"), BigDecimal("1717.22"), BigDecimal("1718.34"), BigDecimal("1716.38"), BigDecimal("1715.37"), BigDecimal("1716.46"), BigDecimal("1719.39"), BigDecimal("1717.94"), BigDecimal("1722.92"), BigDecimal("1755.97"), BigDecimal("1749.11"), BigDecimal("1742.58"), BigDecimal("1742.88"), BigDecimal("1743.36"), BigDecimal("1742.95"), BigDecimal("1739.68"), BigDecimal("1736.65"), BigDecimal("1739.88"), BigDecimal("1734.35"), BigDecimal("1727.31"), BigDecimal("1728.35"), BigDecimal("1724.05"), BigDecimal("1730.04"), BigDecimal("1726.87"), BigDecimal("1727.71"), BigDecimal("1728.49"), BigDecimal("1729.93"), BigDecimal("1726.37"), BigDecimal("1722.92"), BigDecimal("1726.67"), BigDecimal("1724.76"), BigDecimal("1728.41"), BigDecimal("1729.20"), BigDecimal("1728.20"), BigDecimal("1727.98"), BigDecimal("1729.96"), BigDecimal("1727.80"), BigDecimal("1732.04"), BigDecimal("1730.22"), BigDecimal("1733.16"), BigDecimal("1734.14"), BigDecimal("1734.31"), BigDecimal("1739.62"), BigDecimal("1737.76"), BigDecimal("1739.52"), BigDecimal("1742.98"), BigDecimal("1738.36") // ktlint-disable argument-list-wrapping - ), - minPrice = Price("1632.46"), - maxPrice = Price("1922.83"), - periodPriceChangePercentage = Percentage("7.06") - ), - chartPeriod = ChartPeriod.Week, - isCoinFavourite = true - ), - CoinDetailUiState.Loading, - CoinDetailUiState.Error("No internet connection") - ) -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/DetailsUiStatePreviewProvider.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/DetailsUiStatePreviewProvider.kt new file mode 100644 index 00000000..65228cd6 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/DetailsUiStatePreviewProvider.kt @@ -0,0 +1,174 @@ +package dev.shorthouse.coinwatch.ui.previewdata + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import dev.shorthouse.coinwatch.model.CoinChart +import dev.shorthouse.coinwatch.model.CoinDetails +import dev.shorthouse.coinwatch.model.Percentage +import dev.shorthouse.coinwatch.model.Price +import dev.shorthouse.coinwatch.ui.model.ChartPeriod +import dev.shorthouse.coinwatch.ui.screen.details.DetailsUiState +import java.math.BigDecimal +import kotlinx.collections.immutable.persistentListOf + +class DetailsUiStatePreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + DetailsUiState.Success( + CoinDetails( + id = "ethereum", + name = "Ethereum", + symbol = "ETH", + imageUrl = "https://cdn.coinranking.com/rk4RKHOuW/eth.svg", + currentPrice = Price("1879.14"), + marketCap = Price("225722901094"), + marketCapRank = "2", + volume24h = "6,627,669,115", + circulatingSupply = "120,186,525", + allTimeHigh = Price("4878.26"), + allTimeHighDate = "10 Nov 2021", + listedDate = "7 Aug 2015" + ), + CoinChart( + prices = persistentListOf( + BigDecimal("1755.19"), + BigDecimal("1749.71"), + BigDecimal("1750.94"), + BigDecimal("1748.44"), + BigDecimal("1743.98"), + BigDecimal("1740.25"), + BigDecimal("1737.53"), + BigDecimal("1730.56"), + BigDecimal("1738.12"), + BigDecimal("1736.10"), + BigDecimal("1740.20"), + BigDecimal("1740.64"), + BigDecimal("1741.49"), + BigDecimal("1738.87"), + BigDecimal("1734.92"), + BigDecimal("1736.79"), + BigDecimal("1743.53"), + BigDecimal("1743.21"), + BigDecimal("1744.75"), + BigDecimal("1744.85"), + BigDecimal("1741.76"), + BigDecimal("1741.46"), + BigDecimal("1739.82"), + BigDecimal("1740.15"), + BigDecimal("1745.08"), + BigDecimal("1743.29"), + BigDecimal("1746.12"), + BigDecimal("1745.99"), + BigDecimal("1744.89"), + BigDecimal("1741.10"), + BigDecimal("1741.91"), + BigDecimal("1738.47"), + BigDecimal("1737.67"), + BigDecimal("1741.82"), + BigDecimal("1735.95"), + BigDecimal("1728.11"), + BigDecimal("1657.23"), + BigDecimal("1649.89"), + BigDecimal("1649.71"), + BigDecimal("1650.68"), + BigDecimal("1654.04"), + BigDecimal("1648.55"), + BigDecimal("1650.10"), + BigDecimal("1651.87"), + BigDecimal("1651.29"), + BigDecimal("1642.75"), + BigDecimal("1637.79"), + BigDecimal("1635.80"), + BigDecimal("1637.01"), + BigDecimal("1632.46"), + BigDecimal("1633.31"), + BigDecimal("1640.08"), + BigDecimal("1638.61"), + BigDecimal("1645.47"), + BigDecimal("1643.50"), + BigDecimal("1640.57"), + BigDecimal("1640.41"), + BigDecimal("1641.38"), + BigDecimal("1660.21"), + BigDecimal("1665.73"), + BigDecimal("1660.33"), + BigDecimal("1665.65"), + BigDecimal("1664.11"), + BigDecimal("1665.71"), + BigDecimal("1661.90"), + BigDecimal("1661.17"), + BigDecimal("1662.54"), + BigDecimal("1665.58"), + BigDecimal("1666.27"), + BigDecimal("1669.82"), + BigDecimal("1671.34"), + BigDecimal("1669.87"), + BigDecimal("1670.62"), + BigDecimal("1668.97"), + BigDecimal("1668.86"), + BigDecimal("1664.58"), + BigDecimal("1665.96"), + BigDecimal("1664.53"), + BigDecimal("1656.15"), + BigDecimal("1670.91"), + BigDecimal("1685.59"), + BigDecimal("1693.69"), + BigDecimal("1718.10"), + BigDecimal("1719.56"), + BigDecimal("1724.42"), + BigDecimal("1717.22"), + BigDecimal("1718.34"), + BigDecimal("1716.38"), + BigDecimal("1715.37"), + BigDecimal("1716.46"), + BigDecimal("1719.39"), + BigDecimal("1717.94"), + BigDecimal("1722.92"), + BigDecimal("1755.97"), + BigDecimal("1749.11"), + BigDecimal("1742.58"), + BigDecimal("1742.88"), + BigDecimal("1743.36"), + BigDecimal("1742.95"), + BigDecimal("1739.68"), + BigDecimal("1736.65"), + BigDecimal("1739.88"), + BigDecimal("1734.35"), + BigDecimal("1727.31"), + BigDecimal("1728.35"), + BigDecimal("1724.05"), + BigDecimal("1730.04"), + BigDecimal("1726.87"), + BigDecimal("1727.71"), + BigDecimal("1728.49"), + BigDecimal("1729.93"), + BigDecimal("1726.37"), + BigDecimal("1722.92"), + BigDecimal("1726.67"), + BigDecimal("1724.76"), + BigDecimal("1728.41"), + BigDecimal("1729.20"), + BigDecimal("1728.20"), + BigDecimal("1727.98"), + BigDecimal("1729.96"), + BigDecimal("1727.80"), + BigDecimal("1732.04"), + BigDecimal("1730.22"), + BigDecimal("1733.16"), + BigDecimal("1734.14"), + BigDecimal("1734.31"), + BigDecimal("1739.62"), + BigDecimal("1737.76"), + BigDecimal("1739.52"), + BigDecimal("1742.98"), + BigDecimal("1738.36") // ktlint-disable argument-list-wrapping + ), + minPrice = Price("1632.46"), + maxPrice = Price("1922.83"), + periodPriceChangePercentage = Percentage("7.06") + ), + chartPeriod = ChartPeriod.Week, + isCoinFavourite = true + ), + DetailsUiState.Error("No internet connection"), + DetailsUiState.Loading + ) +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/FavouritesUiStatePreviewProvider.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/FavouritesUiStatePreviewProvider.kt new file mode 100644 index 00000000..b12719ad --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/FavouritesUiStatePreviewProvider.kt @@ -0,0 +1,232 @@ +package dev.shorthouse.coinwatch.ui.previewdata + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import dev.shorthouse.coinwatch.model.Coin +import dev.shorthouse.coinwatch.model.Percentage +import dev.shorthouse.coinwatch.model.Price +import dev.shorthouse.coinwatch.ui.previewdata.FavouritesPreviewData.favouriteCoins +import dev.shorthouse.coinwatch.ui.screen.favourites.FavouritesUiState +import java.math.BigDecimal +import kotlinx.collections.immutable.persistentListOf + +class FavouritesUiStatePreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + FavouritesUiState.Success( + favouriteCoins = favouriteCoins + ), + FavouritesUiState.Success( + favouriteCoins = persistentListOf() + ), + FavouritesUiState.Error("No internet connection"), + FavouritesUiState.Loading + ) +} + +private object FavouritesPreviewData { + val favouriteCoins = persistentListOf( + Coin( + id = "bitcoin", + symbol = "BTC", + name = "Bitcoin", + imageUrl = "https://cdn.coinranking.com/bOabBYkcX/bitcoin_btc.svg", + currentPrice = Price("29446.336548759988"), + priceChangePercentage24h = Percentage("0.76833"), + prices24h = persistentListOf( + BigDecimal("29245.370873051394"), + BigDecimal("29205.501195094886"), + BigDecimal("29210.97710800848"), + BigDecimal("29183.90996906209"), + BigDecimal("29191.187134377586"), + BigDecimal("29167.309535190096"), + BigDecimal("29223.071887272858"), + BigDecimal("29307.753433422175"), + BigDecimal("29267.687825355235"), + BigDecimal("29313.499192934243"), + BigDecimal("29296.218518715148"), + BigDecimal("29276.651666477588"), + BigDecimal("29343.71801186576"), + BigDecimal("29354.73988657794"), + BigDecimal("29614.69857297837"), + BigDecimal("29473.762709346545"), + BigDecimal("29460.63779255003"), + BigDecimal("29363.672907978616"), + BigDecimal("29325.29799021886"), + BigDecimal("29370.611267446548"), + BigDecimal("29390.15178296929"), + BigDecimal("29428.222505493162"), + BigDecimal("29475.12359313808"), + BigDecimal("29471.20179209623") + ) + ), + Coin( + id = "ethereum", + symbol = "ETH", + name = "Ethereum", + imageUrl = "https://cdn.coinranking.com/rk4RKHOuW/eth.svg", + currentPrice = Price("1875.473083380222"), + priceChangePercentage24h = Percentage("-1.11008"), + prices24h = persistentListOf( + BigDecimal("1879.89804628163"), + BigDecimal("1877.1265051203513"), + BigDecimal("1874.813847463032"), + BigDecimal("1872.5227299255032"), + BigDecimal("1868.6028895583647"), + BigDecimal("1871.0393773711166"), + BigDecimal("1870.0560363427128"), + BigDecimal("1870.8836588622398"), + BigDecimal("1883.596820245353"), + BigDecimal("1872.2660234438479"), + BigDecimal("1870.3643514336038"), + BigDecimal("1857.714042675004"), + BigDecimal("1859.699110729761"), + BigDecimal("1860.670790300295"), + BigDecimal("1857.800775098814"), + BigDecimal("1858.8543780601171"), + BigDecimal("1854.9767268239643"), + BigDecimal("1850.7124615073333"), + BigDecimal("1851.8376138000401"), + BigDecimal("1850.1796229972815"), + BigDecimal("1854.8824120105778"), + BigDecimal("1853.3272421902477"), + BigDecimal("1857.8290158859397"), + BigDecimal("1859.4549720388395") + ) + ), + Coin( + id = "tether", + symbol = "USDT", + name = "Tether USD", + imageUrl = "https://cdn.coinranking.com/mgHqwlCLj/usdt.svg", + currentPrice = Price("1.00"), + priceChangePercentage24h = Percentage("0.00"), + prices24h = persistentListOf( + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00"), + BigDecimal("1.00") + ) + ), + Coin( + id = "binancecoin", + symbol = "BNB", + name = "BNB", + imageUrl = "https://cdn.coinranking.com/B1N19L_dZ/bnb.svg", + currentPrice = Price("242.13321783678734"), + priceChangePercentage24h = Percentage("1.84955"), + prices24h = persistentListOf( + BigDecimal("238.07237986085968"), + BigDecimal("237.59065248042927"), + BigDecimal("237.62300826740525"), + BigDecimal("237.2262878988098"), + BigDecimal("237.55818626006544"), + BigDecimal("236.80571195718406"), + BigDecimal("237.64781722479938"), + BigDecimal("238.2193416170009"), + BigDecimal("238.15348489842916"), + BigDecimal("238.20808952580057"), + BigDecimal("237.78606577278475"), + BigDecimal("237.09906305700125"), + BigDecimal("238.36365737933727"), + BigDecimal("238.5692322030582"), + BigDecimal("239.75072819043407"), + BigDecimal("239.16062125843843"), + BigDecimal("239.00025751516466"), + BigDecimal("238.94901761793733"), + BigDecimal("238.5714730594989"), + BigDecimal("239.27886677723362"), + BigDecimal("239.67490966723844"), + BigDecimal("240.13674947839255"), + BigDecimal("240.41687032176682"), + BigDecimal("241.82729323371586") + ) + ), + Coin( + id = "ripple", + symbol = "XRP", + name = "XRP", + imageUrl = "https://cdn.coinranking.com/B1oPuTyfX/xrp.svg", + currentPrice = Price("0.7142802333064954"), + priceChangePercentage24h = Percentage("1.77031"), + prices24h = persistentListOf( + BigDecimal("0.7078633715412483"), + BigDecimal("0.703154172261876"), + BigDecimal("0.6994823867542781"), + BigDecimal("0.7014706603483004"), + BigDecimal("0.69879109571246"), + BigDecimal("0.6966649080752425"), + BigDecimal("0.6975200860526335"), + BigDecimal("0.7011758683759688"), + BigDecimal("0.7021223773179766"), + BigDecimal("0.7023799603937112"), + BigDecimal("0.7044909385003845"), + BigDecimal("0.7017835251269512"), + BigDecimal("0.6995375362059472"), + BigDecimal("0.7143777711709876"), + BigDecimal("0.7125634338075278"), + BigDecimal("0.727321981146483"), + BigDecimal("0.7198675986002214"), + BigDecimal("0.7175166290060175"), + BigDecimal("0.7158774882632872"), + BigDecimal("0.7091036220562065"), + BigDecimal("0.7123303286388961"), + BigDecimal("0.7156576118999355"), + BigDecimal("0.7192302623965658"), + BigDecimal("0.7186324625859829") + ) + ), + Coin( + id = "Polkadot", + symbol = "DOT", + name = "Polkadot", + imageUrl = "https://cdn.coinranking.com/V3NSSybv-/polkadot-dot.svg", + currentPrice = Price("4.422860504529326"), + priceChangePercentage24h = Percentage("-0.44"), + prices24h = persistentListOf( + BigDecimal("4.4335207642244985"), + BigDecimal("4.419218533934902"), + BigDecimal("4.408466485673207"), + BigDecimal("4.4294324727491805"), + BigDecimal("4.413899208406151"), + BigDecimal("4.401393755728434"), + BigDecimal("4.396723632911107"), + BigDecimal("4.377061345398131"), + BigDecimal("4.3560039819830845"), + BigDecimal("4.3399040314183175"), + BigDecimal("4.353164049533105"), + BigDecimal("4.350395484668915"), + BigDecimal("4.33731487488839"), + BigDecimal("4.351328494851948"), + BigDecimal("4.411811911359132"), + BigDecimal("4.430526467556776"), + BigDecimal("4.42281998566154"), + BigDecimal("4.426950307081649"), + BigDecimal("4.414644575485274"), + BigDecimal("4.4112137336313175"), + BigDecimal("4.399984935305785"), + BigDecimal("4.413983474703376"), + BigDecimal("4.424187893749479"), + BigDecimal("4.421437665534955") + ) + ) + ) +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinListUiStatePreviewProvider.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/ListUiStatePreviewProvider.kt similarity index 63% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinListUiStatePreviewProvider.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/ListUiStatePreviewProvider.kt index 5faf6cdb..372d02ee 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinListUiStatePreviewProvider.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/ListUiStatePreviewProvider.kt @@ -4,27 +4,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import dev.shorthouse.coinwatch.model.Coin import dev.shorthouse.coinwatch.model.Percentage import dev.shorthouse.coinwatch.model.Price -import dev.shorthouse.coinwatch.ui.model.TimeOfDay import dev.shorthouse.coinwatch.ui.previewdata.CoinListPreviewData.coins -import dev.shorthouse.coinwatch.ui.previewdata.CoinListPreviewData.favouriteCoins -import dev.shorthouse.coinwatch.ui.screen.list.CoinListUiState +import dev.shorthouse.coinwatch.ui.screen.list.ListUiState import java.math.BigDecimal import kotlinx.collections.immutable.persistentListOf -class CoinListUiStatePreviewProvider : PreviewParameterProvider { +class ListUiStatePreviewProvider : PreviewParameterProvider { override val values = sequenceOf( - CoinListUiState.Success( - coins = coins, - favouriteCoins = favouriteCoins, - timeOfDay = TimeOfDay.Evening + ListUiState.Success( + coins = coins ), - CoinListUiState.Success( - coins = persistentListOf(), - favouriteCoins = persistentListOf(), - timeOfDay = TimeOfDay.Morning + ListUiState.Success( + coins = persistentListOf() ), - CoinListUiState.Loading, - CoinListUiState.Error("No internet connection") + ListUiState.Error("No internet connection"), + ListUiState.Loading ) } @@ -235,109 +229,4 @@ private object CoinListPreviewData { ) ) ) - - val favouriteCoins = persistentListOf( - Coin( - id = "bitcoin", - symbol = "BTC", - name = "Bitcoin", - imageUrl = "https://cdn.coinranking.com/bOabBYkcX/bitcoin_btc.svg", - currentPrice = Price("29446.336548759988"), - priceChangePercentage24h = Percentage("0.76833"), - prices24h = persistentListOf( - BigDecimal("29245.370873051394"), - BigDecimal("29205.501195094886"), - BigDecimal("29210.97710800848"), - BigDecimal("29183.90996906209"), - BigDecimal("29191.187134377586"), - BigDecimal("29167.309535190096"), - BigDecimal("29223.071887272858"), - BigDecimal("29307.753433422175"), - BigDecimal("29267.687825355235"), - BigDecimal("29313.499192934243"), - BigDecimal("29296.218518715148"), - BigDecimal("29276.651666477588"), - BigDecimal("29343.71801186576"), - BigDecimal("29354.73988657794"), - BigDecimal("29614.69857297837"), - BigDecimal("29473.762709346545"), - BigDecimal("29460.63779255003"), - BigDecimal("29363.672907978616"), - BigDecimal("29325.29799021886"), - BigDecimal("29370.611267446548"), - BigDecimal("29390.15178296929"), - BigDecimal("29428.222505493162"), - BigDecimal("29475.12359313808"), - BigDecimal("29471.20179209623") - ) - ), - Coin( - id = "ethereum", - symbol = "ETH", - name = "Ethereum", - imageUrl = "https://cdn.coinranking.com/rk4RKHOuW/eth.svg", - currentPrice = Price("1875.473083380222"), - priceChangePercentage24h = Percentage("-1.11008"), - prices24h = persistentListOf( - BigDecimal("1879.89804628163"), - BigDecimal("1877.1265051203513"), - BigDecimal("1874.813847463032"), - BigDecimal("1872.5227299255032"), - BigDecimal("1868.6028895583647"), - BigDecimal("1871.0393773711166"), - BigDecimal("1870.0560363427128"), - BigDecimal("1870.8836588622398"), - BigDecimal("1883.596820245353"), - BigDecimal("1872.2660234438479"), - BigDecimal("1870.3643514336038"), - BigDecimal("1857.714042675004"), - BigDecimal("1859.699110729761"), - BigDecimal("1860.670790300295"), - BigDecimal("1857.800775098814"), - BigDecimal("1858.8543780601171"), - BigDecimal("1854.9767268239643"), - BigDecimal("1850.7124615073333"), - BigDecimal("1851.8376138000401"), - BigDecimal("1850.1796229972815"), - BigDecimal("1854.8824120105778"), - BigDecimal("1853.3272421902477"), - BigDecimal("1857.8290158859397"), - BigDecimal("1859.4549720388395") - ) - ), - Coin( - id = "polygon", - symbol = "MATIC", - name = "Polygon", - imageUrl = "https://cdn.coinranking.com/M-pwilaq-/polygon-matic-logo.svg", - currentPrice = Price("0.5396174533730119"), - priceChangePercentage24h = Percentage("1.77031"), - prices24h = persistentListOf( - BigDecimal("0.7078633715412483"), - BigDecimal("0.703154172261876"), - BigDecimal("0.6994823867542781"), - BigDecimal("0.7014706603483004"), - BigDecimal("0.69879109571246"), - BigDecimal("0.6966649080752425"), - BigDecimal("0.6975200860526335"), - BigDecimal("0.7011758683759688"), - BigDecimal("0.7021223773179766"), - BigDecimal("0.7023799603937112"), - BigDecimal("0.7044909385003845"), - BigDecimal("0.7017835251269512"), - BigDecimal("0.6995375362059472"), - BigDecimal("0.7143777711709876"), - BigDecimal("0.7125634338075278"), - BigDecimal("0.727321981146483"), - BigDecimal("0.7198675986002214"), - BigDecimal("0.7175166290060175"), - BigDecimal("0.7158774882632872"), - BigDecimal("0.7091036220562065"), - BigDecimal("0.7123303286388961"), - BigDecimal("0.7156576118999355"), - BigDecimal("0.7192302623965658"), - BigDecimal("0.7186324625859829") - ) - ) - ) } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinSearchUiStatePreviewProvider.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/SearchUiStatePreviewProvider.kt similarity index 75% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinSearchUiStatePreviewProvider.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/SearchUiStatePreviewProvider.kt index 779a9028..4571df7e 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/CoinSearchUiStatePreviewProvider.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/previewdata/SearchUiStatePreviewProvider.kt @@ -3,19 +3,23 @@ package dev.shorthouse.coinwatch.ui.previewdata import androidx.compose.ui.tooling.preview.PreviewParameterProvider import dev.shorthouse.coinwatch.model.SearchCoin import dev.shorthouse.coinwatch.ui.previewdata.CoinSearchPreviewData.searchResults -import dev.shorthouse.coinwatch.ui.screen.search.CoinSearchUiState +import dev.shorthouse.coinwatch.ui.screen.search.SearchUiState import kotlinx.collections.immutable.persistentListOf -class CoinSearchUiStatePreviewProvider : PreviewParameterProvider { +class SearchUiStatePreviewProvider : PreviewParameterProvider { override val values = sequenceOf( - CoinSearchUiState.Success( + SearchUiState.Success( searchResults = searchResults, queryHasNoResults = false ), - CoinSearchUiState.Loading, - CoinSearchUiState.Error( + SearchUiState.Success( + searchResults = persistentListOf(), + queryHasNoResults = true + ), + SearchUiState.Error( message = "Error searching coins" - ) + ), + SearchUiState.Loading ) } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailUiState.kt deleted file mode 100644 index c35bc4ac..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailUiState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.detail - -import dev.shorthouse.coinwatch.model.CoinChart -import dev.shorthouse.coinwatch.model.CoinDetail -import dev.shorthouse.coinwatch.ui.model.ChartPeriod - -sealed interface CoinDetailUiState { - object Loading : CoinDetailUiState - data class Success( - val coinDetail: CoinDetail, - val coinChart: CoinChart, - val chartPeriod: ChartPeriod, - val isCoinFavourite: Boolean - ) : CoinDetailUiState - - data class Error(val message: String?) : CoinDetailUiState -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinDetailSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinDetailSkeletonLoader.kt deleted file mode 100644 index 4ede1f76..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinDetailSkeletonLoader.kt +++ /dev/null @@ -1,121 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.StarOutline -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -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 dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.ui.component.SkeletonSurface -import dev.shorthouse.coinwatch.ui.theme.AppTheme - -@Composable -fun CoinDetailSkeletonLoader(modifier: Modifier = Modifier) { - Scaffold( - topBar = { - SkeletonTopAppBar() - }, - content = { scaffoldPadding -> - SkeletonContent(modifier = Modifier.padding(scaffoldPadding)) - }, - modifier = modifier - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SkeletonTopAppBar(modifier: Modifier = Modifier) { - LargeTopAppBar( - navigationIcon = { - IconButton(onClick = {}) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - contentDescription = stringResource(R.string.cd_top_bar_back) - ) - } - }, - title = {}, - actions = { - IconButton(onClick = {}) { - Icon( - imageVector = Icons.Rounded.StarOutline, - contentDescription = stringResource(R.string.cd_top_bar_favourite), - tint = MaterialTheme.colorScheme.onBackground - ) - } - }, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ), - modifier = modifier - ) -} - -@Composable -private fun SkeletonContent(modifier: Modifier = Modifier) { - Column(modifier = modifier.padding(horizontal = 12.dp)) { - SkeletonSurface( - modifier = Modifier - .fillMaxWidth() - .height(374.dp) - ) - - Spacer(Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.title_chart_range), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - - SkeletonSurface( - modifier = Modifier - .fillMaxWidth() - .height(91.dp) - ) - - Spacer(Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.card_header_market_stats), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - - SkeletonSurface( - shape = MaterialTheme.shapes.medium.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) - ), - modifier = Modifier.fillMaxSize() - ) - } -} - -@Preview -@Composable -private fun CoinDetailSkeletonLoaderPreview() { - AppTheme { - CoinDetailSkeletonLoader() - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailScreen.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsScreen.kt similarity index 70% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailScreen.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsScreen.kt index 0f1f35c3..a406367e 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailScreen.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsScreen.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail +package dev.shorthouse.coinwatch.ui.screen.details import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,8 +11,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.rounded.StarOutline +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,93 +37,103 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import coil.compose.AsyncImage import coil.decode.SvgDecoder import coil.request.ImageRequest import dev.shorthouse.coinwatch.R import dev.shorthouse.coinwatch.model.CoinChart -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.ui.component.ErrorState import dev.shorthouse.coinwatch.ui.model.ChartPeriod -import dev.shorthouse.coinwatch.ui.previewdata.CoinDetailUiStatePreviewProvider -import dev.shorthouse.coinwatch.ui.screen.detail.component.CoinChartCard -import dev.shorthouse.coinwatch.ui.screen.detail.component.CoinChartRangeCard -import dev.shorthouse.coinwatch.ui.screen.detail.component.CoinDetailSkeletonLoader -import dev.shorthouse.coinwatch.ui.screen.detail.component.MarketStatsCard +import dev.shorthouse.coinwatch.ui.previewdata.DetailsUiStatePreviewProvider +import dev.shorthouse.coinwatch.ui.screen.details.component.CoinChartCard +import dev.shorthouse.coinwatch.ui.screen.details.component.CoinChartRangeCard +import dev.shorthouse.coinwatch.ui.screen.details.component.DetailsEmptyTopBar +import dev.shorthouse.coinwatch.ui.screen.details.component.DetailsSkeletonLoader +import dev.shorthouse.coinwatch.ui.screen.details.component.MarketStatsCard import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable -fun CoinDetailScreen( - navController: NavController, - viewModel: CoinDetailViewModel = hiltViewModel() +fun CoinDetailsScreen( + viewModel: DetailsViewModel = hiltViewModel(), + onNavigateUp: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - CoinDetailScreen( + CoinDetailsScreen( uiState = uiState, - onNavigateUp = { navController.navigateUp() }, + onNavigateUp = onNavigateUp, onClickFavouriteCoin = { viewModel.toggleIsCoinFavourite() }, onClickChartPeriod = { viewModel.updateChartPeriod(it) }, - onErrorRetry = { viewModel.initialiseUiState() } + onRefresh = { viewModel.initialiseUiState() } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CoinDetailScreen( - uiState: CoinDetailUiState, +fun CoinDetailsScreen( + uiState: DetailsUiState, onNavigateUp: () -> Unit, onClickFavouriteCoin: () -> Unit, onClickChartPeriod: (ChartPeriod) -> Unit, - onErrorRetry: () -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - when (uiState) { - is CoinDetailUiState.Success -> { - Scaffold( - topBar = { - CoinDetailTopBar( - coinDetail = uiState.coinDetail, + Scaffold( + topBar = { + when (uiState) { + is DetailsUiState.Success -> { + CoinDetailsTopBar( + coinDetails = uiState.coinDetails, isCoinFavourite = uiState.isCoinFavourite, onNavigateUp = onNavigateUp, onClickFavouriteCoin = onClickFavouriteCoin, scrollBehavior = scrollBehavior ) - }, - content = { scaffoldPadding -> - CoinDetailContent( - coinDetail = uiState.coinDetail, + } + + else -> { + DetailsEmptyTopBar( + onNavigateUp = onNavigateUp + ) + } + } + }, + content = { scaffoldPadding -> + when (uiState) { + is DetailsUiState.Success -> { + CoinDetailsContent( + coinDetails = uiState.coinDetails, coinChart = uiState.coinChart, chartPeriod = uiState.chartPeriod, onClickChartPeriod = onClickChartPeriod, modifier = Modifier.padding(scaffoldPadding) ) - }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) - } + } - is CoinDetailUiState.Loading -> { - CoinDetailSkeletonLoader() - } + is DetailsUiState.Error -> { + ErrorState( + message = uiState.message, + onRetry = onRefresh, + modifier = Modifier.padding(scaffoldPadding) + ) + } - is CoinDetailUiState.Error -> { - ErrorState( - message = uiState.message, - onRetry = onErrorRetry, - onNavigateUp = onNavigateUp - ) - } - } + is DetailsUiState.Loading -> { + DetailsSkeletonLoader(modifier = Modifier.padding(scaffoldPadding)) + } + } + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun CoinDetailTopBar( - coinDetail: CoinDetail, +fun CoinDetailsTopBar( + coinDetails: CoinDetails, isCoinFavourite: Boolean, onNavigateUp: () -> Unit, onClickFavouriteCoin: () -> Unit, @@ -150,13 +160,13 @@ private fun CoinDetailTopBar( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = coinDetail.name, + text = coinDetails.name, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( - text = coinDetail.symbol, + text = coinDetails.symbol, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -166,7 +176,7 @@ private fun CoinDetailTopBar( AsyncImage( model = imageBuilder - .data(coinDetail.imageUrl) + .data(coinDetails.imageUrl) .build(), contentDescription = null, modifier = Modifier @@ -179,9 +189,9 @@ private fun CoinDetailTopBar( IconButton(onClick = onClickFavouriteCoin) { Icon( imageVector = if (isCoinFavourite) { - Icons.Rounded.Star + Icons.Rounded.Favorite } else { - Icons.Rounded.StarOutline + Icons.Rounded.FavoriteBorder }, contentDescription = stringResource(R.string.cd_top_bar_favourite), tint = MaterialTheme.colorScheme.onBackground @@ -198,8 +208,8 @@ private fun CoinDetailTopBar( } @Composable -private fun CoinDetailContent( - coinDetail: CoinDetail, +fun CoinDetailsContent( + coinDetails: CoinDetails, coinChart: CoinChart, chartPeriod: ChartPeriod, onClickChartPeriod: (ChartPeriod) -> Unit, @@ -210,10 +220,10 @@ private fun CoinDetailContent( .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(start = 12.dp, end = 12.dp, bottom = 12.dp) - .testTag("coin_detail_content") + .testTag("coin_details_content") ) { CoinChartCard( - currentPrice = coinDetail.currentPrice, + currentPrice = coinDetails.currentPrice, prices = coinChart.prices, periodPriceChangePercentage = coinChart.periodPriceChangePercentage, chartPeriod = chartPeriod, @@ -230,7 +240,7 @@ private fun CoinDetailContent( Spacer(Modifier.height(8.dp)) CoinChartRangeCard( - currentPrice = coinDetail.currentPrice, + currentPrice = coinDetails.currentPrice, minPrice = coinChart.minPrice, maxPrice = coinChart.maxPrice, isPricesEmpty = coinChart.prices.isEmpty() @@ -245,22 +255,22 @@ private fun CoinDetailContent( Spacer(Modifier.height(8.dp)) - MarketStatsCard(coinDetail = coinDetail) + MarketStatsCard(coinDetails = coinDetails) } } @Composable @Preview private fun CoinDetailScreenPreview( - @PreviewParameter(CoinDetailUiStatePreviewProvider::class) uiState: CoinDetailUiState + @PreviewParameter(DetailsUiStatePreviewProvider::class) uiState: DetailsUiState ) { AppTheme { - CoinDetailScreen( + CoinDetailsScreen( uiState = uiState, onNavigateUp = {}, onClickFavouriteCoin = {}, onClickChartPeriod = {}, - onErrorRetry = {} + onRefresh = {} ) } } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsUiState.kt new file mode 100644 index 00000000..436ea808 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsUiState.kt @@ -0,0 +1,17 @@ +package dev.shorthouse.coinwatch.ui.screen.details + +import dev.shorthouse.coinwatch.model.CoinChart +import dev.shorthouse.coinwatch.model.CoinDetails +import dev.shorthouse.coinwatch.ui.model.ChartPeriod + +sealed interface DetailsUiState { + data class Success( + val coinDetails: CoinDetails, + val coinChart: CoinChart, + val chartPeriod: ChartPeriod, + val isCoinFavourite: Boolean + ) : DetailsUiState + + data class Error(val message: String?) : DetailsUiState + object Loading : DetailsUiState +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsViewModel.kt similarity index 76% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModel.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsViewModel.kt index 7e128949..73b3befc 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModel.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/DetailsViewModel.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail +package dev.shorthouse.coinwatch.ui.screen.details import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -9,7 +9,7 @@ import dev.shorthouse.coinwatch.common.Result import dev.shorthouse.coinwatch.data.source.local.model.FavouriteCoin import dev.shorthouse.coinwatch.domain.DeleteFavouriteCoinUseCase import dev.shorthouse.coinwatch.domain.GetCoinChartUseCase -import dev.shorthouse.coinwatch.domain.GetCoinDetailUseCase +import dev.shorthouse.coinwatch.domain.GetCoinDetailsUseCase import dev.shorthouse.coinwatch.domain.InsertFavouriteCoinUseCase import dev.shorthouse.coinwatch.domain.IsCoinFavouriteUseCase import dev.shorthouse.coinwatch.ui.model.ChartPeriod @@ -25,15 +25,15 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel -class CoinDetailViewModel @Inject constructor( +class DetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val getCoinDetailUseCase: GetCoinDetailUseCase, + private val getCoinDetailsUseCase: GetCoinDetailsUseCase, private val getCoinChartUseCase: GetCoinChartUseCase, private val isCoinFavouriteUseCase: IsCoinFavouriteUseCase, private val insertFavouriteCoinUseCase: InsertFavouriteCoinUseCase, private val deleteFavouriteCoinUseCase: DeleteFavouriteCoinUseCase ) : ViewModel() { - private val _uiState = MutableStateFlow(CoinDetailUiState.Loading) + private val _uiState = MutableStateFlow(DetailsUiState.Loading) val uiState = _uiState.asStateFlow() private val chartPeriodFlow = MutableStateFlow(ChartPeriod.Day) @@ -45,14 +45,14 @@ class CoinDetailViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) fun initialiseUiState() { - _uiState.update { CoinDetailUiState.Loading } + _uiState.update { DetailsUiState.Loading } if (coinId == null) { - _uiState.update { CoinDetailUiState.Error("Invalid coin ID") } + _uiState.update { DetailsUiState.Error("Invalid coin ID") } return } - val coinDetailFlow = getCoinDetailUseCase(coinId = coinId) + val coinDetailsFlow = getCoinDetailsUseCase(coinId = coinId) val coinChartFlow = chartPeriodFlow.flatMapLatest { chartPeriod -> getCoinChartUseCase( coinId = coinId, @@ -62,29 +62,29 @@ class CoinDetailViewModel @Inject constructor( val isCoinFavouriteFlow = isCoinFavouriteUseCase(coinId = coinId) combine( - coinDetailFlow, + coinDetailsFlow, coinChartFlow, isCoinFavouriteFlow - ) { coinDetailResult, coinChartResult, isCoinFavouriteResult -> + ) { coinDetailsResult, coinChartResult, isCoinFavouriteResult -> when { - coinDetailResult is Result.Error -> { - _uiState.update { CoinDetailUiState.Error(coinDetailResult.message) } + coinDetailsResult is Result.Error -> { + _uiState.update { DetailsUiState.Error(coinDetailsResult.message) } } coinChartResult is Result.Error -> { - _uiState.update { CoinDetailUiState.Error(coinChartResult.message) } + _uiState.update { DetailsUiState.Error(coinChartResult.message) } } isCoinFavouriteResult is Result.Error -> { - _uiState.update { CoinDetailUiState.Error(isCoinFavouriteResult.message) } + _uiState.update { DetailsUiState.Error(isCoinFavouriteResult.message) } } - coinDetailResult is Result.Success && + coinDetailsResult is Result.Success && coinChartResult is Result.Success && isCoinFavouriteResult is Result.Success -> { _uiState.update { - CoinDetailUiState.Success( - coinDetail = coinDetailResult.data, + DetailsUiState.Success( + coinDetails = coinDetailsResult.data, coinChart = coinChartResult.data, chartPeriod = chartPeriodFlow.value, isCoinFavourite = isCoinFavouriteResult.data diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartPeriodSelector.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartPeriodSelector.kt similarity index 98% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartPeriodSelector.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartPeriodSelector.kt index b566de86..d480ba2e 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartPeriodSelector.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartPeriodSelector.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component +package dev.shorthouse.coinwatch.ui.screen.details.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartRangeLine.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartRangeLine.kt similarity index 97% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartRangeLine.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartRangeLine.kt index f07ce5f4..44fc001d 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/ChartRangeLine.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/ChartRangeLine.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component +package dev.shorthouse.coinwatch.ui.screen.details.component import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartCard.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartCard.kt similarity index 98% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartCard.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartCard.kt index f04b45c1..4641caa1 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartCard.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartCard.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component +package dev.shorthouse.coinwatch.ui.screen.details.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -104,7 +104,7 @@ fun CoinChartCard( @Preview @Composable -fun CoinChartCardPreview() { +private fun CoinChartCardPreview() { AppTheme { CoinChartCard( currentPrice = Price("1000"), diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartRangeCard.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartRangeCard.kt similarity index 97% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartRangeCard.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartRangeCard.kt index c3453c83..5895777f 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/CoinChartRangeCard.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/CoinChartRangeCard.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component +package dev.shorthouse.coinwatch.ui.screen.details.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -97,7 +97,7 @@ fun CoinChartRangeCard( @Preview @Composable -fun ChartRangeCardPreview() { +private fun ChartRangeCardPreview() { AppTheme { CoinChartRangeCard( currentPrice = Price("80.0"), diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsEmptyTopAppBar.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsEmptyTopAppBar.kt new file mode 100644 index 00000000..480cce97 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsEmptyTopAppBar.kt @@ -0,0 +1,49 @@ +package dev.shorthouse.coinwatch.ui.screen.details.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailsEmptyTopBar( + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + LargeTopAppBar( + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.cd_top_bar_back) + ) + } + }, + title = {}, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ), + modifier = modifier + ) +} + +@Preview +@Composable +private fun DetailsEmptyTopBar() { + AppTheme { + DetailsEmptyTopBar( + onNavigateUp = {}, + ) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsSkeletonLoader.kt new file mode 100644 index 00000000..a6f5a117 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/DetailsSkeletonLoader.kt @@ -0,0 +1,76 @@ +package dev.shorthouse.coinwatch.ui.screen.details.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme +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 dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.component.SkeletonSurface +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun DetailsSkeletonLoader(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + SkeletonSurface( + modifier = Modifier + .fillMaxWidth() + .height(374.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.title_chart_range), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(Modifier.height(8.dp)) + + SkeletonSurface( + modifier = Modifier + .fillMaxWidth() + .height(91.dp) + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.card_header_market_stats), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(Modifier.height(8.dp)) + + SkeletonSurface( + shape = MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ), + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun CoinDetailSkeletonLoaderPreview() { + AppTheme { + DetailsSkeletonLoader() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/MarketStatsCard.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/MarketStatsCard.kt similarity index 73% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/MarketStatsCard.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/MarketStatsCard.kt index 6ec6d13d..3276a9f1 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/detail/component/MarketStatsCard.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/details/component/MarketStatsCard.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail.component +package dev.shorthouse.coinwatch.ui.screen.details.component import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -17,44 +17,44 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.model.Price import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable fun MarketStatsCard( - coinDetail: CoinDetail, + coinDetails: CoinDetails, modifier: Modifier = Modifier ) { - val coinDetailItems = remember(coinDetail) { + val coinDetailsItems = remember(coinDetails) { listOf( - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_market_cap_rank, - value = coinDetail.marketCapRank + value = coinDetails.marketCapRank ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_market_cap, - value = coinDetail.marketCap.formattedAmount + value = coinDetails.marketCap.formattedAmount ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_volume_24h, - value = coinDetail.volume24h + value = coinDetails.volume24h ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_circulating_supply, - value = coinDetail.circulatingSupply + value = coinDetails.circulatingSupply ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_ath, - value = coinDetail.allTimeHigh.formattedAmount + value = coinDetails.allTimeHigh.formattedAmount ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_ath_date, - value = coinDetail.allTimeHighDate + value = coinDetails.allTimeHighDate ), - CoinDetailListItem( + CoinDetailsListItem( nameId = R.string.list_item_listed_date, - value = coinDetail.listedDate + value = coinDetails.listedDate ) ) } @@ -65,8 +65,8 @@ fun MarketStatsCard( ) { Column(modifier = Modifier.padding(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - coinDetailItems.forEachIndexed { coinDetailIndex, coinDetailListItem -> - if (coinDetailIndex != 0) { + coinDetailsItems.forEachIndexed { coinDetailsIndex, coinDetailsListItem -> + if (coinDetailsIndex != 0) { Divider(color = MaterialTheme.colorScheme.background) } @@ -75,13 +75,13 @@ fun MarketStatsCard( modifier = Modifier.fillMaxWidth() ) { Text( - text = stringResource(coinDetailListItem.nameId), + text = stringResource(coinDetailsListItem.nameId), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = coinDetailListItem.value, + text = coinDetailsListItem.value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface ) @@ -92,7 +92,7 @@ fun MarketStatsCard( } } -private data class CoinDetailListItem( +private data class CoinDetailsListItem( @StringRes val nameId: Int, val value: String ) @@ -102,7 +102,7 @@ private data class CoinDetailListItem( private fun MarketStatsCardPreview() { AppTheme { MarketStatsCard( - coinDetail = CoinDetail( + coinDetails = CoinDetails( id = "ethereum", name = "Ethereum", symbol = "ETH", diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesScreen.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesScreen.kt new file mode 100644 index 00000000..75308ec4 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesScreen.kt @@ -0,0 +1,166 @@ +package dev.shorthouse.coinwatch.ui.screen.favourites + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.model.Coin +import dev.shorthouse.coinwatch.ui.component.ErrorState +import dev.shorthouse.coinwatch.ui.previewdata.FavouritesUiStatePreviewProvider +import dev.shorthouse.coinwatch.ui.screen.favourites.component.CoinFavouriteItem +import dev.shorthouse.coinwatch.ui.screen.favourites.component.FavouritesEmptyState +import dev.shorthouse.coinwatch.ui.screen.favourites.component.FavouritesSkeletonLoader +import dev.shorthouse.coinwatch.ui.theme.AppTheme +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun FavouritesScreen( + viewModel: FavouritesViewModel = hiltViewModel(), + onNavigateDetails: (String) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + FavouriteScreen( + uiState = uiState, + onCoinClick = { coin -> + onNavigateDetails(coin.id) + }, + onRefresh = { viewModel.initialiseUiState() } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavouriteScreen( + uiState: FavouritesUiState, + onCoinClick: (Coin) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + topBar = { + FavouritesTopBar( + scrollBehavior = scrollBehavior + ) + }, + content = { scaffoldPadding -> + when (uiState) { + is FavouritesUiState.Success -> { + FavouritesContent( + favouriteCoins = uiState.favouriteCoins, + onCoinClick = onCoinClick, + modifier = Modifier.padding(scaffoldPadding) + ) + } + + is FavouritesUiState.Error -> { + ErrorState( + message = stringResource(R.string.error_state_favourite_coins), + onRetry = onRefresh, + modifier = Modifier.padding(scaffoldPadding) + ) + } + + is FavouritesUiState.Loading -> { + FavouritesSkeletonLoader(modifier = Modifier.padding(scaffoldPadding)) + } + } + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavouritesTopBar( + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier +) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.favourites_screen), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background + ), + scrollBehavior = scrollBehavior, + modifier = modifier + ) +} + +@Composable +fun FavouritesContent( + favouriteCoins: ImmutableList, + onCoinClick: (Coin) -> Unit, + modifier: Modifier = Modifier +) { + if (favouriteCoins.isEmpty()) { + FavouritesEmptyState( + modifier = modifier + .fillMaxSize() + .padding(12.dp) + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + items( + count = favouriteCoins.size, + key = { favouriteCoins[it].id }, + itemContent = { index -> + val favouriteCoinItem = favouriteCoins[index] + + CoinFavouriteItem( + coin = favouriteCoinItem, + onCoinClick = { onCoinClick(favouriteCoinItem) } + ) + } + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun FavouritesScreenPreview( + @PreviewParameter(FavouritesUiStatePreviewProvider::class) uiState: FavouritesUiState +) { + AppTheme { + FavouriteScreen( + uiState = uiState, + onCoinClick = {}, + onRefresh = {} + ) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesUiState.kt new file mode 100644 index 00000000..23e60bdf --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesUiState.kt @@ -0,0 +1,13 @@ +package dev.shorthouse.coinwatch.ui.screen.favourites + +import dev.shorthouse.coinwatch.model.Coin +import kotlinx.collections.immutable.ImmutableList + +sealed interface FavouritesUiState { + data class Success( + val favouriteCoins: ImmutableList + ) : FavouritesUiState + + data class Error(val message: String?) : FavouritesUiState + object Loading : FavouritesUiState +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesViewModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesViewModel.kt new file mode 100644 index 00000000..29fa9231 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/FavouritesViewModel.kt @@ -0,0 +1,46 @@ +package dev.shorthouse.coinwatch.ui.screen.favourites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.shorthouse.coinwatch.common.Result +import dev.shorthouse.coinwatch.domain.GetFavouriteCoinsUseCase +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +@HiltViewModel +class FavouritesViewModel @Inject constructor( + private val getFavouriteCoinsUseCase: GetFavouriteCoinsUseCase +) : ViewModel() { + private val _uiState = MutableStateFlow(FavouritesUiState.Loading) + val uiState = _uiState.asStateFlow() + + init { + initialiseUiState() + } + + fun initialiseUiState() { + _uiState.update { FavouritesUiState.Loading } + + val favouriteCoinsFlow = getFavouriteCoinsUseCase() + + favouriteCoinsFlow.onEach { favouriteCoinsResult -> + when (favouriteCoinsResult) { + is Result.Error -> { + _uiState.update { FavouritesUiState.Error(favouriteCoinsResult.message) } + } + + is Result.Success -> { + _uiState.update { + FavouritesUiState.Success(favouriteCoinsResult.data.toImmutableList()) + } + } + } + }.launchIn(viewModelScope) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinFavouriteItem.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouriteItem.kt similarity index 96% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinFavouriteItem.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouriteItem.kt index 1da17034..6186ff4b 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinFavouriteItem.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouriteItem.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.list.component +package dev.shorthouse.coinwatch.ui.screen.favourites.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -50,7 +50,7 @@ fun CoinFavouriteItem( Surface( shape = MaterialTheme.shapes.medium, modifier = modifier - .size(width = 140.dp, height = 200.dp) + .height(200.dp) .clickable { onCoinClick(coin) } ) { Column { @@ -65,7 +65,7 @@ fun CoinFavouriteItem( modifier = Modifier.size(32.dp) ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(16.dp)) Column { Text( diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesEmptyState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesEmptyState.kt new file mode 100644 index 00000000..2261bb45 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesEmptyState.kt @@ -0,0 +1,63 @@ +package dev.shorthouse.coinwatch.ui.screen.favourites.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.component.EmptyState +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun FavouritesEmptyState(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + EmptyState( + image = painterResource(R.drawable.empty_state_favourite_coins), + title = stringResource(R.string.empty_state_favourite_coins_title), + subtitle = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.empty_state_favourite_coins_subtitle_start), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Icon( + imageVector = Icons.Rounded.FavoriteBorder, + contentDescription = stringResource(R.string.cd_top_bar_favourite), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(22.dp) + .padding(start = 2.dp, top = 2.dp, end = 2.dp) + ) + + Text( + text = stringResource(R.string.empty_state_favourite_coins_subtitle_end), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +@Composable +@Preview +private fun FavouritesEmptyStatePreview() { + AppTheme { + FavouritesEmptyState() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesSkeletonLoader.kt new file mode 100644 index 00000000..fca070d7 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/favourites/component/FavouritesSkeletonLoader.kt @@ -0,0 +1,42 @@ +package dev.shorthouse.coinwatch.ui.screen.favourites.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.shorthouse.coinwatch.ui.component.SkeletonSurface +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun FavouritesSkeletonLoader(modifier: Modifier = Modifier) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + userScrollEnabled = false, + modifier = modifier.fillMaxSize() + ) { + repeat(20) { + item { + SkeletonSurface( + modifier = Modifier.height(200.dp) + ) + } + } + } +} + +@Composable +@Preview +private fun FavouritesSkeletonLoaderPreview() { + AppTheme { + FavouritesSkeletonLoader() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListScreen.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListScreen.kt deleted file mode 100644 index fbf28dca..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListScreen.kt +++ /dev/null @@ -1,330 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.list - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.model.Coin -import dev.shorthouse.coinwatch.navigation.Screen -import dev.shorthouse.coinwatch.ui.component.ErrorState -import dev.shorthouse.coinwatch.ui.model.TimeOfDay -import dev.shorthouse.coinwatch.ui.previewdata.CoinListUiStatePreviewProvider -import dev.shorthouse.coinwatch.ui.screen.list.component.CoinFavouriteItem -import dev.shorthouse.coinwatch.ui.screen.list.component.CoinListItem -import dev.shorthouse.coinwatch.ui.screen.list.component.CoinListSkeletonLoader -import dev.shorthouse.coinwatch.ui.screen.list.component.CoinsEmptyState -import dev.shorthouse.coinwatch.ui.screen.list.component.FavouriteCoinsEmptyState -import dev.shorthouse.coinwatch.ui.theme.AppTheme -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.launch - -@Composable -fun CoinListScreen( - navController: NavController, - viewModel: CoinListViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - CoinListScreen( - uiState = uiState, - onCoinClick = { coin -> - navController.navigate(Screen.CoinDetail.route + "/${coin.id}") - }, - onNavigateSearch = { navController.navigate(Screen.CoinSearch.route) }, - onErrorRetry = { viewModel.initialiseUiState() } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CoinListScreen( - uiState: CoinListUiState, - onCoinClick: (Coin) -> Unit, - onNavigateSearch: () -> Unit, - onErrorRetry: () -> Unit, - modifier: Modifier = Modifier -) { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - - when (uiState) { - is CoinListUiState.Success -> { - val lazyListState = rememberLazyListState() - val scope = rememberCoroutineScope() - val showJumpToTopFab by remember { - derivedStateOf { - lazyListState.firstVisibleItemIndex > 0 - } - } - - Scaffold( - topBar = { - CoinListTopBar( - timeOfDay = uiState.timeOfDay, - scrollBehavior = scrollBehavior, - onNavigateSearch = onNavigateSearch - ) - }, - content = { scaffoldPadding -> - CoinListContent( - coins = uiState.coins, - favouriteCoins = uiState.favouriteCoins, - onCoinClick = onCoinClick, - lazyListState = lazyListState, - modifier = Modifier.padding(scaffoldPadding) - ) - }, - floatingActionButton = { - AnimatedVisibility( - visible = showJumpToTopFab, - enter = scaleIn(), - exit = scaleOut() - ) { - SmallFloatingActionButton( - onClick = { - scope.launch { - scrollBehavior.state.heightOffset = 0f - lazyListState.animateScrollToItem(0) - } - }, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - content = { - Icon( - imageVector = Icons.Rounded.KeyboardDoubleArrowUp, - contentDescription = stringResource(R.string.cd_list_scroll_top) - ) - } - ) - } - }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) - } - - is CoinListUiState.Loading -> { - CoinListSkeletonLoader() - } - - is CoinListUiState.Error -> { - ErrorState( - message = uiState.message, - onRetry = onErrorRetry - ) - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun CoinListTopBar( - timeOfDay: TimeOfDay, - scrollBehavior: TopAppBarScrollBehavior, - onNavigateSearch: () -> Unit, - modifier: Modifier = Modifier -) { - TopAppBar( - title = { - Text( - text = when (timeOfDay) { - TimeOfDay.Morning -> stringResource(R.string.good_morning) - TimeOfDay.Afternoon -> stringResource(R.string.good_afternoon) - TimeOfDay.Evening -> stringResource(R.string.good_evening) - }, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.offset(x = (-4).dp) - ) - }, - actions = { - IconButton(onClick = onNavigateSearch) { - Icon( - imageVector = Icons.Rounded.Search, - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = stringResource(R.string.cd_top_app_bar_search) - ) - } - }, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - scrolledContainerColor = MaterialTheme.colorScheme.background - ), - scrollBehavior = scrollBehavior, - modifier = modifier - ) -} - -@Composable -private fun CoinListContent( - coins: ImmutableList, - favouriteCoins: ImmutableList, - onCoinClick: (Coin) -> Unit, - lazyListState: LazyListState, - modifier: Modifier = Modifier -) { - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues(start = 12.dp, top = 12.dp), - modifier = modifier - ) { - item { - Text( - text = stringResource(R.string.header_favourites), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - - if (favouriteCoins.isEmpty()) { - FavouriteCoinsEmptyState( - modifier = Modifier.padding(end = 12.dp) - ) - } else { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(end = 12.dp) - ) { - items( - count = favouriteCoins.size, - key = { favouriteCoins[it].id }, - itemContent = { index -> - val favouriteCoinItem = favouriteCoins[index] - - CoinFavouriteItem( - coin = favouriteCoinItem, - onCoinClick = { onCoinClick(favouriteCoinItem) } - ) - } - ) - } - } - - Spacer(Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.header_coins), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - } - - if (coins.isEmpty()) { - item { - CoinsEmptyState() - } - } else { - items( - count = coins.size, - key = { coins[it].id }, - itemContent = { index -> - val coinListItem = coins[index] - - val cardShape = when (index) { - 0 -> MaterialTheme.shapes.medium.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) - ) - - coins.lastIndex -> MaterialTheme.shapes.medium.copy( - topStart = CornerSize(0.dp), - topEnd = CornerSize(0.dp) - ) - - else -> RoundedCornerShape(0.dp) - } - - CoinListItem( - coin = coinListItem, - onCoinClick = { onCoinClick(coinListItem) }, - cardShape = cardShape, - modifier = Modifier.padding(end = 12.dp) - ) - } - ) - } - - item { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Spacer(Modifier.height(12.dp)) - - Text( - text = stringResource(R.string.cant_find_coin_prompt), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = stringResource(R.string.search_coin_prompt), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(Modifier.height(12.dp)) - } - } - } -} - -@Composable -@Preview(showBackground = true) -private fun CoinListScreenPreview( - @PreviewParameter(CoinListUiStatePreviewProvider::class) uiState: CoinListUiState -) { - AppTheme { - CoinListScreen( - uiState = uiState, - onCoinClick = {}, - onNavigateSearch = {}, - onErrorRetry = {} - ) - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListUiState.kt deleted file mode 100644 index ef426577..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListUiState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.list - -import dev.shorthouse.coinwatch.model.Coin -import dev.shorthouse.coinwatch.ui.model.TimeOfDay -import kotlinx.collections.immutable.ImmutableList - -sealed interface CoinListUiState { - object Loading : CoinListUiState - data class Success( - val coins: ImmutableList, - val favouriteCoins: ImmutableList, - val timeOfDay: TimeOfDay - ) : CoinListUiState - - data class Error(val message: String?) : CoinListUiState -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModel.kt deleted file mode 100644 index 04a25148..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.list - -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import dev.shorthouse.coinwatch.common.Result -import dev.shorthouse.coinwatch.domain.GetCoinsUseCase -import dev.shorthouse.coinwatch.domain.GetFavouriteCoinsUseCase -import dev.shorthouse.coinwatch.ui.model.TimeOfDay -import java.time.LocalTime -import javax.inject.Inject -import kotlin.time.Duration.Companion.minutes -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.update - -@HiltViewModel -class CoinListViewModel @Inject constructor( - private val getCoinsUseCase: GetCoinsUseCase, - private val getFavouriteCoinsUseCase: GetFavouriteCoinsUseCase -) : ViewModel() { - private val _uiState = MutableStateFlow(CoinListUiState.Loading) - val uiState = _uiState.asStateFlow() - - init { - initialiseUiState() - } - - fun initialiseUiState() { - _uiState.update { CoinListUiState.Loading } - - val coinsFlow = getCoinsUseCase() - val favouriteCoinsFlow = getFavouriteCoinsUseCase() - val currentHourFlow = flow { - emit(LocalTime.now().hour) - delay(5.minutes.inWholeMilliseconds) - } - - combine( - coinsFlow, - favouriteCoinsFlow, - currentHourFlow - ) { coinsResult, favouriteCoinsResult, currentHour -> - when { - coinsResult is Result.Error -> { - _uiState.update { CoinListUiState.Error(coinsResult.message) } - } - - favouriteCoinsResult is Result.Error -> { - _uiState.update { CoinListUiState.Error(favouriteCoinsResult.message) } - } - - coinsResult is Result.Success && favouriteCoinsResult is Result.Success -> { - val coins = coinsResult.data.toImmutableList() - val favouriteCoins = favouriteCoinsResult.data.toImmutableList() - val timeOfDay = calculateTimeOfDay(currentHour) - - _uiState.update { - CoinListUiState.Success( - coins = coins, - favouriteCoins = favouriteCoins, - timeOfDay = timeOfDay - ) - } - } - } - }.launchIn(viewModelScope) - } - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun calculateTimeOfDay(currentHour: Int): TimeOfDay { - return when (currentHour) { - in 0..11 -> TimeOfDay.Morning - in 12..17 -> TimeOfDay.Afternoon - else -> TimeOfDay.Evening - } - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListScreen.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListScreen.kt new file mode 100644 index 00000000..77b44974 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListScreen.kt @@ -0,0 +1,232 @@ +package dev.shorthouse.coinwatch.ui.screen.list + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.model.Coin +import dev.shorthouse.coinwatch.ui.component.ErrorState +import dev.shorthouse.coinwatch.ui.previewdata.ListUiStatePreviewProvider +import dev.shorthouse.coinwatch.ui.screen.list.component.ListEmptyState +import dev.shorthouse.coinwatch.ui.screen.list.component.ListItem +import dev.shorthouse.coinwatch.ui.screen.list.component.ListSkeletonLoader +import dev.shorthouse.coinwatch.ui.screen.list.component.SearchPrompt +import dev.shorthouse.coinwatch.ui.theme.AppTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch + +@Composable +fun ListScreen( + viewModel: ListViewModel = hiltViewModel(), + onNavigateDetails: (String) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ListScreen( + uiState = uiState, + onCoinClick = { coin -> + onNavigateDetails(coin.id) + }, + onRefresh = { viewModel.initialiseUiState() } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen( + uiState: ListUiState, + onCoinClick: (Coin) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val lazyListState = rememberLazyListState() + val scope = rememberCoroutineScope() + val showJumpToTopFab by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + Scaffold( + topBar = { + ListTopBar(scrollBehavior = scrollBehavior) + }, + content = { scaffoldPadding -> + when (uiState) { + is ListUiState.Success -> { + ListContent( + coins = uiState.coins, + onCoinClick = onCoinClick, + lazyListState = lazyListState, + modifier = Modifier.padding(scaffoldPadding) + ) + } + + is ListUiState.Error -> { + ErrorState( + message = uiState.message, + onRetry = onRefresh, + modifier = Modifier.padding(scaffoldPadding) + ) + } + + is ListUiState.Loading -> { + ListSkeletonLoader(modifier = Modifier.padding(scaffoldPadding)) + } + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = showJumpToTopFab, + enter = scaleIn(), + exit = scaleOut() + ) { + SmallFloatingActionButton( + onClick = { + scope.launch { + scrollBehavior.state.heightOffset = 0f + lazyListState.animateScrollToItem(0) + } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 12.dp + ), + content = { + Icon( + imageVector = Icons.Rounded.KeyboardDoubleArrowUp, + contentDescription = stringResource(R.string.cd_list_scroll_top) + ) + } + ) + } + }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ListTopBar( + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier +) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.market_screen), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background + ), + scrollBehavior = scrollBehavior, + modifier = modifier + ) +} + +@Composable +fun ListContent( + coins: ImmutableList, + onCoinClick: (Coin) -> Unit, + lazyListState: LazyListState, + modifier: Modifier = Modifier +) { + if (coins.isEmpty()) { + ListEmptyState( + modifier = modifier + .fillMaxSize() + .padding(12.dp) + ) + } else { + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = modifier.fillMaxSize() + ) { + items( + count = coins.size, + key = { coins[it].id }, + itemContent = { index -> + val coinListItem = coins[index] + + val cardShape = when (index) { + 0 -> MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ) + + coins.lastIndex -> MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) + + else -> RoundedCornerShape(0.dp) + } + + ListItem( + coin = coinListItem, + onCoinClick = { onCoinClick(coinListItem) }, + cardShape = cardShape + ) + } + ) + + item { + SearchPrompt(modifier = Modifier.padding(vertical = 12.dp)) + } + } + } +} + +@Composable +@Preview(showBackground = true) +private fun ListScreenPreview( + @PreviewParameter(ListUiStatePreviewProvider::class) uiState: ListUiState +) { + AppTheme { + ListScreen( + uiState = uiState, + onCoinClick = {}, + onRefresh = {} + ) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListUiState.kt new file mode 100644 index 00000000..4f18317c --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListUiState.kt @@ -0,0 +1,13 @@ +package dev.shorthouse.coinwatch.ui.screen.list + +import dev.shorthouse.coinwatch.model.Coin +import kotlinx.collections.immutable.ImmutableList + +sealed interface ListUiState { + data class Success( + val coins: ImmutableList + ) : ListUiState + + data class Error(val message: String?) : ListUiState + object Loading : ListUiState +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListViewModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListViewModel.kt new file mode 100644 index 00000000..c5ebd5c9 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/ListViewModel.kt @@ -0,0 +1,50 @@ +package dev.shorthouse.coinwatch.ui.screen.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.shorthouse.coinwatch.common.Result +import dev.shorthouse.coinwatch.domain.GetCoinsUseCase +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +@HiltViewModel +class ListViewModel @Inject constructor( + private val getCoinsUseCase: GetCoinsUseCase +) : ViewModel() { + private val _uiState = MutableStateFlow(ListUiState.Loading) + val uiState = _uiState.asStateFlow() + + init { + initialiseUiState() + } + + fun initialiseUiState() { + _uiState.update { ListUiState.Loading } + + val coinsFlow = getCoinsUseCase() + + coinsFlow.onEach { coinsResult -> + when (coinsResult) { + is Result.Error -> { + _uiState.update { ListUiState.Error(coinsResult.message) } + } + + is Result.Success -> { + val coins = coinsResult.data.toImmutableList() + + _uiState.update { + ListUiState.Success( + coins = coins + ) + } + } + } + }.launchIn(viewModelScope) + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListSkeletonLoader.kt deleted file mode 100644 index dbb3c45a..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListSkeletonLoader.kt +++ /dev/null @@ -1,103 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.list.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -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 dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.ui.component.SkeletonSurface -import dev.shorthouse.coinwatch.ui.theme.AppTheme - -@Composable -fun CoinListSkeletonLoader(modifier: Modifier = Modifier) { - Scaffold( - topBar = { - SkeletonTopAppBar() - }, - content = { scaffoldPadding -> - SkeletonContent( - modifier = Modifier.padding(scaffoldPadding) - ) - }, - modifier = modifier - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SkeletonTopAppBar( - modifier: Modifier = Modifier -) { - TopAppBar( - title = {}, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ), - modifier = modifier - ) -} - -@Composable -private fun SkeletonContent( - modifier: Modifier = Modifier -) { - Column(modifier = modifier.padding(start = 12.dp, top = 12.dp)) { - Text( - text = stringResource(R.string.header_favourites), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - repeat(12) { - SkeletonSurface( - modifier = Modifier.size(width = 140.dp, height = 200.dp) - ) - } - } - - Spacer(Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.header_coins), - style = MaterialTheme.typography.titleMedium - ) - - Spacer(Modifier.height(8.dp)) - - SkeletonSurface( - shape = MaterialTheme.shapes.medium.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) - ), - modifier = Modifier - .fillMaxSize() - .padding(end = 12.dp) - ) - } -} - -@Composable -@Preview -fun CoinListSkeletonLoaderPreview() { - AppTheme { - CoinListSkeletonLoader() - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/FavouriteCoinsEmptyState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/FavouriteCoinsEmptyState.kt deleted file mode 100644 index ce2183ab..00000000 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/FavouriteCoinsEmptyState.kt +++ /dev/null @@ -1,86 +0,0 @@ -package dev.shorthouse.coinwatch.ui.screen.list.component - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.StarOutline -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.shorthouse.coinwatch.R -import dev.shorthouse.coinwatch.ui.theme.AppTheme - -@Composable -fun FavouriteCoinsEmptyState(modifier: Modifier = Modifier) { - Surface( - shape = MaterialTheme.shapes.medium, - modifier = modifier - .fillMaxWidth() - .height(200.dp) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(12.dp) - ) { - Image( - painter = painterResource(R.drawable.empty_state_favourite_coins), - contentDescription = null, - modifier = Modifier.weight(1f) - ) - - Spacer(Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.empty_state_favourite_coins_title), - style = MaterialTheme.typography.titleSmall - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.empty_state_favourite_coins_subtitle_start), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Icon( - imageVector = Icons.Rounded.StarOutline, - contentDescription = stringResource(R.string.cd_top_bar_favourite), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(22.dp) - .padding(start = 2.dp, top = 2.dp, end = 2.dp) - ) - - Text( - text = stringResource(R.string.empty_state_favourite_coins_subtitle_end), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Preview -@Composable -private fun FavouriteCoinsEmptyStatePreview() { - AppTheme { - FavouriteCoinsEmptyState() - } -} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListEmptyState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListEmptyState.kt new file mode 100644 index 00000000..d0b9bbcd --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListEmptyState.kt @@ -0,0 +1,39 @@ +package dev.shorthouse.coinwatch.ui.screen.list.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.component.EmptyState +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun ListEmptyState(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + EmptyState( + image = painterResource(R.drawable.empty_state_coins), + title = stringResource(R.string.empty_state_coins_title), + subtitle = { + Text( + text = stringResource(R.string.empty_state_coins_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier + ) + } +} + +@Composable +@Preview +private fun ListEmptyStatePreview() { + AppTheme { + ListEmptyState() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListItem.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListItem.kt similarity index 96% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListItem.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListItem.kt index bbaac2a3..ed1604db 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/CoinListItem.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListItem.kt @@ -26,12 +26,11 @@ import coil.decode.SvgDecoder import coil.request.ImageRequest import dev.shorthouse.coinwatch.model.Coin import dev.shorthouse.coinwatch.ui.component.PercentageChange -import dev.shorthouse.coinwatch.ui.component.PercentageChangeChip import dev.shorthouse.coinwatch.ui.previewdata.CoinPreviewProvider import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable -fun CoinListItem( +fun ListItem( coin: Coin, onCoinClick: (Coin) -> Unit, cardShape: Shape, @@ -97,11 +96,11 @@ fun CoinListItem( @Composable @Preview -private fun CoinListItemPreview( +private fun ListItemPreview( @PreviewParameter(CoinPreviewProvider::class) coin: Coin ) { AppTheme { - CoinListItem( + ListItem( coin = coin, onCoinClick = {}, cardShape = MaterialTheme.shapes.medium diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListSkeletonLoader.kt new file mode 100644 index 00000000..dff928e6 --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/ListSkeletonLoader.kt @@ -0,0 +1,38 @@ +package dev.shorthouse.coinwatch.ui.screen.list.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.shorthouse.coinwatch.ui.component.SkeletonSurface +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun ListSkeletonLoader(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp) + ) { + SkeletonSurface( + shape = MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ), + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +@Preview +private fun ListSkeletonLoaderPreview() { + AppTheme { + ListSkeletonLoader() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/SearchPrompt.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/SearchPrompt.kt new file mode 100644 index 00000000..4a7eed1f --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/list/component/SearchPrompt.kt @@ -0,0 +1,46 @@ +package dev.shorthouse.coinwatch.ui.screen.list.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun SearchPrompt(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.cant_find_coin_prompt), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.search_coin_prompt), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +@Preview +private fun SearchPromptPreview() { + AppTheme { + SearchPrompt() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchScreen.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchScreen.kt similarity index 50% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchScreen.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchScreen.kt index 93a0540d..231347b3 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchScreen.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchScreen.kt @@ -1,15 +1,14 @@ package dev.shorthouse.coinwatch.ui.screen.search -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -19,13 +18,9 @@ import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -33,88 +28,45 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import dev.shorthouse.coinwatch.R import dev.shorthouse.coinwatch.model.SearchCoin -import dev.shorthouse.coinwatch.navigation.Screen import dev.shorthouse.coinwatch.ui.component.ErrorState -import dev.shorthouse.coinwatch.ui.previewdata.CoinSearchUiStatePreviewProvider -import dev.shorthouse.coinwatch.ui.screen.search.component.CoinSearchListItem +import dev.shorthouse.coinwatch.ui.previewdata.SearchUiStatePreviewProvider import dev.shorthouse.coinwatch.ui.screen.search.component.SearchEmptyState +import dev.shorthouse.coinwatch.ui.screen.search.component.SearchListItem +import dev.shorthouse.coinwatch.ui.screen.search.component.SearchSkeletonLoader import dev.shorthouse.coinwatch.ui.theme.AppTheme import kotlinx.collections.immutable.ImmutableList @Composable -fun CoinSearchScreen( - navController: NavController, - viewModel: CoinSearchViewModel = hiltViewModel() +fun SearchScreen( + viewModel: SearchViewModel = hiltViewModel(), + onNavigateDetails: (String) -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - CoinSearchScreen( + SearchScreen( uiState = uiState, searchQuery = viewModel.searchQuery, onSearchQueryChange = { viewModel.updateSearchQuery(it) }, - onNavigateUp = { navController.navigateUp() }, onCoinClick = { coin -> - navController.navigate(Screen.CoinDetail.route + "/${coin.id}") { - popUpTo(Screen.CoinSearch.route) { inclusive = true } - } + onNavigateDetails(coin.id) }, - onErrorRetry = { viewModel.initialiseUiState() } + onRefresh = { viewModel.initialiseUiState() } ) } -@Composable -fun CoinSearchScreen( - uiState: CoinSearchUiState, - searchQuery: String, - onSearchQueryChange: (String) -> Unit, - onNavigateUp: () -> Unit, - onCoinClick: (SearchCoin) -> Unit, - onErrorRetry: () -> Unit, - modifier: Modifier = Modifier -) { - when (uiState) { - is CoinSearchUiState.Success -> { - CoinSearchContent( - searchResults = uiState.searchResults, - searchQuery = searchQuery, - isSearchResultsEmpty = uiState.queryHasNoResults, - onSearchQueryChange = onSearchQueryChange, - onNavigateUp = onNavigateUp, - onCoinClick = onCoinClick, - modifier = modifier - ) - } - - is CoinSearchUiState.Error -> { - ErrorState( - message = uiState.message, - onRetry = onErrorRetry, - onNavigateUp = onNavigateUp - ) - } - - is CoinSearchUiState.Loading -> { - Box(modifier = Modifier.fillMaxSize()) - } - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -fun CoinSearchContent( - searchResults: ImmutableList, +fun SearchScreen( + uiState: SearchUiState, searchQuery: String, - isSearchResultsEmpty: Boolean, onSearchQueryChange: (String) -> Unit, - onNavigateUp: () -> Unit, onCoinClick: (SearchCoin) -> Unit, + onRefresh: () -> Unit, modifier: Modifier = Modifier ) { val keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } SearchBar( query = searchQuery, @@ -128,9 +80,9 @@ fun CoinSearchContent( ) }, leadingIcon = { - IconButton(onClick = onNavigateUp) { + IconButton(onClick = {}) { Icon( - imageVector = Icons.Rounded.ArrowBack, + imageVector = Icons.Rounded.Search, tint = MaterialTheme.colorScheme.onSurface, contentDescription = stringResource(R.string.cd_top_bar_back) ) @@ -148,81 +100,111 @@ fun CoinSearchContent( } }, content = { - if (isSearchResultsEmpty) { - SearchEmptyState() - } else { - LazyColumn( - contentPadding = PaddingValues(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items( - count = searchResults.size, - key = { index -> searchResults[index].id }, - itemContent = { index -> - val searchCoin = searchResults[index] - - val cardShape = when { - searchResults.size == 1 -> MaterialTheme.shapes.medium - index == 0 -> MaterialTheme.shapes.medium.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) - ) - - index == searchResults.lastIndex -> - MaterialTheme.shapes.medium.copy( - topStart = CornerSize(0.dp), - topEnd = CornerSize(0.dp) - ) - - else -> RoundedCornerShape(0.dp) - } + when (uiState) { + is SearchUiState.Success -> { + SearchContent( + searchResults = uiState.searchResults, + queryHasNoResults = uiState.queryHasNoResults, + onCoinClick = onCoinClick + ) + } - CoinSearchListItem( - searchCoin = searchCoin, - onCoinClick = onCoinClick, - cardShape = cardShape - ) - } + is SearchUiState.Error -> { + ErrorState( + message = uiState.message, + onRetry = onRefresh, + modifier = Modifier.padding(bottom = 10.dp) ) } + + is SearchUiState.Loading -> { + SearchSkeletonLoader() + } } }, colors = SearchBarDefaults.colors( containerColor = MaterialTheme.colorScheme.background, dividerColor = MaterialTheme.colorScheme.surface, inputFieldColors = TextFieldDefaults.colors( - cursorColor = MaterialTheme.colorScheme.onSurface + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedIndicatorColor = MaterialTheme.colorScheme.surface, + unfocusedIndicatorColor = MaterialTheme.colorScheme.surface, + disabledIndicatorColor = MaterialTheme.colorScheme.surface, + errorIndicatorColor = MaterialTheme.colorScheme.surface ) ), enabled = true, active = true, onActiveChange = {}, tonalElevation = 0.dp, - modifier = modifier.focusRequester(focusRequester).fillMaxSize() + modifier = modifier.fillMaxSize() ) +} - LaunchedEffect(focusRequester) { - focusRequester.requestFocus() - } +@Composable +fun SearchContent( + searchResults: ImmutableList, + queryHasNoResults: Boolean, + onCoinClick: (SearchCoin) -> Unit, + modifier: Modifier = Modifier +) { + if (queryHasNoResults) { + SearchEmptyState( + modifier = modifier + .fillMaxSize() + .padding(12.dp) + ) + } else { + LazyColumn( + contentPadding = PaddingValues(12.dp), + modifier = modifier.fillMaxSize() + ) { + items( + count = searchResults.size, + key = { index -> searchResults[index].id }, + itemContent = { index -> + val searchCoin = searchResults[index] + + val cardShape = when { + searchResults.size == 1 -> MaterialTheme.shapes.medium + + index == 0 -> MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ) + + index == searchResults.lastIndex -> + MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) - BackHandler(enabled = true) { - onNavigateUp() + else -> RoundedCornerShape(0.dp) + } + + SearchListItem( + searchCoin = searchCoin, + onCoinClick = onCoinClick, + cardShape = cardShape + ) + } + ) + } } } @Composable @Preview(showBackground = true) -private fun CoinSearchScreenPreview( - @PreviewParameter(CoinSearchUiStatePreviewProvider::class) uiState: CoinSearchUiState +private fun SearchScreenPreview( + @PreviewParameter(SearchUiStatePreviewProvider::class) uiState: SearchUiState ) { AppTheme { - CoinSearchScreen( + SearchScreen( uiState = uiState, searchQuery = "", onSearchQueryChange = {}, - onNavigateUp = {}, onCoinClick = {}, - onErrorRetry = {} + onRefresh = {} ) } } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchUiState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchUiState.kt similarity index 62% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchUiState.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchUiState.kt index 63616c97..26ba53ae 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchUiState.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchUiState.kt @@ -3,12 +3,12 @@ package dev.shorthouse.coinwatch.ui.screen.search import dev.shorthouse.coinwatch.model.SearchCoin import kotlinx.collections.immutable.ImmutableList -sealed interface CoinSearchUiState { - object Loading : CoinSearchUiState +sealed interface SearchUiState { data class Success( val searchResults: ImmutableList, val queryHasNoResults: Boolean - ) : CoinSearchUiState + ) : SearchUiState - data class Error(val message: String?) : CoinSearchUiState + data class Error(val message: String?) : SearchUiState + object Loading : SearchUiState } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModel.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchViewModel.kt similarity index 89% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModel.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchViewModel.kt index 23975185..d27cad88 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModel.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/SearchViewModel.kt @@ -22,10 +22,10 @@ import kotlinx.coroutines.flow.update @OptIn(FlowPreview::class) @HiltViewModel -class CoinSearchViewModel @Inject constructor( +class SearchViewModel @Inject constructor( private val getCoinSearchResultsUseCase: GetCoinSearchResultsUseCase ) : ViewModel() { - private val _uiState = MutableStateFlow(CoinSearchUiState.Loading) + private val _uiState = MutableStateFlow(SearchUiState.Loading) val uiState = _uiState.asStateFlow() var searchQuery by mutableStateOf("") @@ -45,7 +45,7 @@ class CoinSearchViewModel @Inject constructor( when (result) { is Result.Error -> { _uiState.update { - CoinSearchUiState.Error( + SearchUiState.Error( message = result.message ) } @@ -54,7 +54,7 @@ class CoinSearchViewModel @Inject constructor( val searchResults = result.data.toPersistentList() _uiState.update { - CoinSearchUiState.Success( + SearchUiState.Success( searchResults = searchResults, queryHasNoResults = searchResults.isEmpty() ) @@ -63,7 +63,7 @@ class CoinSearchViewModel @Inject constructor( } } else { _uiState.update { - CoinSearchUiState.Success( + SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchEmptyState.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchEmptyState.kt index a7023add..e2a9bf63 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchEmptyState.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchEmptyState.kt @@ -1,63 +1,31 @@ package dev.shorthouse.coinwatch.ui.screen.search.component -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import dev.shorthouse.coinwatch.R +import dev.shorthouse.coinwatch.ui.component.EmptyState import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable -fun SearchEmptyState( - modifier: Modifier = Modifier -) { - Box(modifier = modifier.background(MaterialTheme.colorScheme.background)) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.7f) - .padding(12.dp) - ) { - Image( - painter = painterResource(R.drawable.empty_state_search), - contentDescription = null, - modifier = Modifier.size(240.dp) - ) - - Spacer(Modifier.height(12.dp)) - - Text( - text = stringResource(R.string.empty_state_search_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = stringResource(R.string.empty_state_search_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } +fun SearchEmptyState(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + EmptyState( + image = painterResource(R.drawable.empty_state_search), + title = stringResource(R.string.empty_state_search_title), + subtitle = { + Text( + text = stringResource(R.string.empty_state_search_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) } } diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/CoinSearchListItem.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchListItem.kt similarity index 97% rename from app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/CoinSearchListItem.kt rename to app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchListItem.kt index ddfe03e2..97ae1fd9 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/CoinSearchListItem.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchListItem.kt @@ -29,7 +29,7 @@ import dev.shorthouse.coinwatch.ui.previewdata.SearchCoinPreviewProvider import dev.shorthouse.coinwatch.ui.theme.AppTheme @Composable -fun CoinSearchListItem( +fun SearchListItem( searchCoin: SearchCoin, onCoinClick: (SearchCoin) -> Unit, cardShape: Shape, @@ -84,11 +84,11 @@ fun CoinSearchListItem( @Composable @Preview -private fun CoinSearchListItemPreview( +private fun SearchListItemPreview( @PreviewParameter(SearchCoinPreviewProvider::class) searchCoin: SearchCoin ) { AppTheme { - CoinSearchListItem( + SearchListItem( searchCoin = searchCoin, onCoinClick = {}, cardShape = MaterialTheme.shapes.medium diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchSkeletonLoader.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchSkeletonLoader.kt new file mode 100644 index 00000000..96d4000b --- /dev/null +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/screen/search/component/SearchSkeletonLoader.kt @@ -0,0 +1,21 @@ +package dev.shorthouse.coinwatch.ui.screen.search.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import dev.shorthouse.coinwatch.ui.theme.AppTheme + +@Composable +fun SearchSkeletonLoader(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize()) +} + +@Composable +@Preview +private fun SearchSkeletonLoaderPreview() { + AppTheme { + SearchSkeletonLoader() + } +} diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Color.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Color.kt index 7137b630..a2a60255 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Color.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Color.kt @@ -2,9 +2,11 @@ package dev.shorthouse.coinwatch.ui.theme import androidx.compose.ui.graphics.Color -val dark_background = Color(0xFF19284D) +val dark_primaryContainer = Color(0xFF384670) +val dark_onPrimaryContainer = Color(0xFFFFFFFF) +val dark_background = Color(0xFF152241) val dark_onBackground = Color(0xFFFFFFFF) -val dark_surface = Color(0xFF253667) +val dark_surface = Color(0xFF223260) val dark_onSurface = Color(0xFFFFFFFF) val dark_onSurfaceVariant = Color(0xFFBDBDBD) diff --git a/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Theme.kt b/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Theme.kt index 7614b668..bccd9cd9 100644 --- a/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Theme.kt +++ b/app/src/main/java/dev/shorthouse/coinwatch/ui/theme/Theme.kt @@ -8,6 +8,8 @@ import com.google.accompanist.systemuicontroller.SystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController private val darkColorScheme = darkColorScheme( + primaryContainer = dark_primaryContainer, + onPrimaryContainer = dark_onPrimaryContainer, background = dark_background, onBackground = dark_onBackground, surface = dark_surface, diff --git a/app/src/main/res/drawable/empty_state_coins.xml b/app/src/main/res/drawable/empty_state_coins.xml index 74d14e85..50a9e523 100644 --- a/app/src/main/res/drawable/empty_state_coins.xml +++ b/app/src/main/res/drawable/empty_state_coins.xml @@ -3,112 +3,112 @@ android:height="532.8dp" android:viewportWidth="423.88" android:viewportHeight="532.8"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/empty_state_favourite_coins.xml b/app/src/main/res/drawable/empty_state_favourite_coins.xml index 436e3c73..28958d32 100644 --- a/app/src/main/res/drawable/empty_state_favourite_coins.xml +++ b/app/src/main/res/drawable/empty_state_favourite_coins.xml @@ -3,90 +3,70 @@ android:height="483.5dp" android:viewportWidth="485.83" android:viewportHeight="483.5"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/empty_state_search.xml b/app/src/main/res/drawable/empty_state_search.xml index e206aa91..9844c3c1 100644 --- a/app/src/main/res/drawable/empty_state_search.xml +++ b/app/src/main/res/drawable/empty_state_search.xml @@ -3,64 +3,64 @@ android:height="515.46dp" android:viewportWidth="552.81" android:viewportHeight="515.46"> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/error_state.xml b/app/src/main/res/drawable/error_state.xml index eb71c8c2..c3e04db8 100644 --- a/app/src/main/res/drawable/error_state.xml +++ b/app/src/main/res/drawable/error_state.xml @@ -3,55 +3,55 @@ android:height="658dp" android:viewportWidth="729" android:viewportHeight="658"> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b6b6a853..736d12e2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,13 @@ - #19284D - #19284D + #152241 + #152241 + #223260 + #6F6C88 + #3F3d56 + #FFFFFF + #F2F2F2 + #97636b + #FFB6B6 + #A0616A diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3980eb4..8423989d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,4 +51,8 @@ Try using the search function! Scroll to top Error + Market + Search + Favourites + Unable to fetch favourite coins diff --git a/app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapperTest.kt b/app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapperTest.kt similarity index 72% rename from app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapperTest.kt rename to app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapperTest.kt index 57612fb8..4cabae40 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailMapperTest.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/data/mapper/CoinDetailsMapperTest.kt @@ -2,27 +2,27 @@ package dev.shorthouse.coinwatch.data.mapper import com.google.common.truth.Truth.assertThat import dev.shorthouse.coinwatch.data.source.remote.model.AllTimeHigh -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailData -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailDataHolder +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsData +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsDataHolder import dev.shorthouse.coinwatch.data.source.remote.model.Supply -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.model.Price import org.junit.Test -class CoinDetailMapperTest { +class CoinDetailsMapperTest { // Class under test - private val coinDetailMapper = CoinDetailMapper() + private val coinDetailsMapper = CoinDetailsMapper() @Test - fun `When coin detail data holder is null should return default values`() { + fun `When coin details data holder is null should return default values`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = null + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = null ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "", name = "", symbol = "", @@ -38,22 +38,22 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } @Test - fun `When coin detail data is null should return default values`() { + fun `When coin details data is null should return default values`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = null + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = null ) ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "", name = "", symbol = "", @@ -69,18 +69,18 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } @Test - fun `When all coin detail values are null should return default values`() { + fun `When all coin details values are null should return default values`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = CoinDetailData( + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = CoinDetailsData( id = null, name = null, symbol = null, @@ -96,7 +96,7 @@ class CoinDetailMapperTest { ) ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "", name = "", symbol = "", @@ -112,18 +112,18 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } @Test fun `When all numbers to format are invalid should return empty values`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = CoinDetailData( + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = CoinDetailsData( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -144,7 +144,7 @@ class CoinDetailMapperTest { ) ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -160,18 +160,18 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } @Test fun `When timestamps are invalid should return default values`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = CoinDetailData( + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = CoinDetailsData( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -192,7 +192,7 @@ class CoinDetailMapperTest { ) ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -208,18 +208,18 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } @Test - fun `When coin detail data has valid values should map as expected`() { + fun `When coin details data has valid values should map as expected`() { // Arrange - val coinDetailApiModel = CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = CoinDetailData( + val coinDetailsApiModel = CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = CoinDetailsData( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -240,7 +240,7 @@ class CoinDetailMapperTest { ) ) - val expectedCoinDetail = CoinDetail( + val expectedCoinDetails = CoinDetails( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -256,9 +256,9 @@ class CoinDetailMapperTest { ) // Act - val coinDetail = coinDetailMapper.mapApiModelToModel(coinDetailApiModel) + val coinDetails = coinDetailsMapper.mapApiModelToModel(coinDetailsApiModel) // Assert - assertThat(coinDetail).isEqualTo(expectedCoinDetail) + assertThat(coinDetails).isEqualTo(expectedCoinDetails) } } diff --git a/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinApi.kt b/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinApi.kt index b191b9df..43265b2d 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinApi.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinApi.kt @@ -4,9 +4,9 @@ import dev.shorthouse.coinwatch.data.source.remote.model.AllTimeHigh import dev.shorthouse.coinwatch.data.source.remote.model.CoinApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartData -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailData -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailDataHolder +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsData +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsDataHolder import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsData import dev.shorthouse.coinwatch.data.source.remote.model.CoinsApiModel @@ -215,13 +215,13 @@ class FakeCoinApi : CoinApi { } } - override suspend fun getCoinDetail( + override suspend fun getCoinDetails( coinId: String, currencyUUID: String - ): Response { + ): Response { when (coinId) { "Qwsogvtv82FCd" -> { - val coinDetail = CoinDetailData( + val coinDetails = CoinDetailsData( id = "Qwsogvtv82FCd", name = "Bitcoin", symbol = "BTC", @@ -241,9 +241,9 @@ class FakeCoinApi : CoinApi { ) return Response.success( - CoinDetailApiModel( - coinDetailDataHolder = CoinDetailDataHolder( - coinDetailData = coinDetail + CoinDetailsApiModel( + coinDetailsDataHolder = CoinDetailsDataHolder( + coinDetailsData = coinDetails ) ) ) @@ -252,7 +252,7 @@ class FakeCoinApi : CoinApi { else -> { return Response.error( 404, - "Coin detail not found".toResponseBody(null) + "Coin details not found".toResponseBody(null) ) } } diff --git a/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinNetworkDataSource.kt b/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinNetworkDataSource.kt index a80beaaf..8b657e83 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinNetworkDataSource.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/data/source/remote/FakeCoinNetworkDataSource.kt @@ -1,7 +1,7 @@ package dev.shorthouse.coinwatch.data.source.remote import dev.shorthouse.coinwatch.data.source.remote.model.CoinChartApiModel -import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailApiModel +import dev.shorthouse.coinwatch.data.source.remote.model.CoinDetailsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinSearchResultsApiModel import dev.shorthouse.coinwatch.data.source.remote.model.CoinsApiModel import retrofit2.Response @@ -13,8 +13,8 @@ class FakeCoinNetworkDataSource( return coinApi.getCoins(coinIds = coinIds) } - override suspend fun getCoinDetail(coinId: String): Response { - return coinApi.getCoinDetail(coinId = coinId) + override suspend fun getCoinDetails(coinId: String): Response { + return coinApi.getCoinDetails(coinId = coinId) } override suspend fun getCoinChart( diff --git a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModelTest.kt b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/details/CoinDetailsViewModelTest.kt similarity index 54% rename from app/src/test/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModelTest.kt rename to app/src/test/java/dev/shorthouse/coinwatch/ui/screen/details/CoinDetailsViewModelTest.kt index 8b371b6c..d9fe7034 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/detail/CoinDetailViewModelTest.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/details/CoinDetailsViewModelTest.kt @@ -1,4 +1,4 @@ -package dev.shorthouse.coinwatch.ui.screen.detail +package dev.shorthouse.coinwatch.ui.screen.details import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat @@ -8,11 +8,11 @@ import dev.shorthouse.coinwatch.common.Result import dev.shorthouse.coinwatch.data.source.local.model.FavouriteCoin import dev.shorthouse.coinwatch.domain.DeleteFavouriteCoinUseCase import dev.shorthouse.coinwatch.domain.GetCoinChartUseCase -import dev.shorthouse.coinwatch.domain.GetCoinDetailUseCase +import dev.shorthouse.coinwatch.domain.GetCoinDetailsUseCase import dev.shorthouse.coinwatch.domain.InsertFavouriteCoinUseCase import dev.shorthouse.coinwatch.domain.IsCoinFavouriteUseCase import dev.shorthouse.coinwatch.model.CoinChart -import dev.shorthouse.coinwatch.model.CoinDetail +import dev.shorthouse.coinwatch.model.CoinDetails import dev.shorthouse.coinwatch.ui.model.ChartPeriod import io.mockk.MockKAnnotations import io.mockk.Runs @@ -30,16 +30,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -class CoinDetailViewModelTest { +class CoinDetailsViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() // Class under test - private lateinit var viewModel: CoinDetailViewModel + private lateinit var viewModel: DetailsViewModel @RelaxedMockK - private lateinit var getCoinDetailUseCase: GetCoinDetailUseCase + private lateinit var getCoinDetailsUseCase: GetCoinDetailsUseCase @RelaxedMockK private lateinit var getCoinChartUseCase: GetCoinChartUseCase @@ -62,9 +62,9 @@ class CoinDetailViewModelTest { every { savedStateHandle.get(Constants.PARAM_COIN_ID) } returns "Qwsogvtv82FCd" - viewModel = CoinDetailViewModel( + viewModel = DetailsViewModel( savedStateHandle = savedStateHandle, - getCoinDetailUseCase = getCoinDetailUseCase, + getCoinDetailsUseCase = getCoinDetailsUseCase, getCoinChartUseCase = getCoinChartUseCase, isCoinFavouriteUseCase = isCoinFavouriteUseCase, insertFavouriteCoinUseCase = insertFavouriteCoinUseCase, @@ -80,7 +80,7 @@ class CoinDetailViewModelTest { @Test fun `When ViewModel is initialised should have loading UI state`() = runTest { // Arrange - val expectedUiState = CoinDetailUiState.Loading + val expectedUiState = DetailsUiState.Loading // Act @@ -89,13 +89,13 @@ class CoinDetailViewModelTest { } @Test - fun `When coin detail returns error should have error UI state`() = runTest { + fun `When coin details returns error should have error UI state`() = runTest { // Arrange val coinChart = mockkClass(CoinChart::class) - val errorMessage = "Coin detail error" - val expectedUiState = CoinDetailUiState.Error(errorMessage) + val errorMessage = "Coin details error" + val expectedUiState = DetailsUiState.Error(errorMessage) - every { getCoinDetailUseCase(any()) } returns flowOf(Result.Error(errorMessage)) + every { getCoinDetailsUseCase(any()) } returns flowOf(Result.Error(errorMessage)) every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Success(coinChart)) every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Success(false)) @@ -110,10 +110,10 @@ class CoinDetailViewModelTest { fun `When coin chart returns error should have error UI state`() = runTest { // Arrange val errorMessage = "Coin chart error" - val coinDetail = mockkClass(CoinDetail::class) - val expectedUiState = CoinDetailUiState.Error(errorMessage) + val coinDetails = mockkClass(CoinDetails::class) + val expectedUiState = DetailsUiState.Error(errorMessage) - every { getCoinDetailUseCase(any()) } returns flowOf(Result.Success(coinDetail)) + every { getCoinDetailsUseCase(any()) } returns flowOf(Result.Success(coinDetails)) every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Error(errorMessage)) every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Success(false)) @@ -128,11 +128,11 @@ class CoinDetailViewModelTest { fun `When is coin favourite returns error should have error UI state`() = runTest { // Arrange val errorMessage = "Coin favourite error" - val coinDetail = mockkClass(CoinDetail::class) + val coinDetails = mockkClass(CoinDetails::class) val coinChart = mockkClass(CoinChart::class) - val expectedUiState = CoinDetailUiState.Error(errorMessage) + val expectedUiState = DetailsUiState.Error(errorMessage) - every { getCoinDetailUseCase(any()) } returns flowOf(Result.Success(coinDetail)) + every { getCoinDetailsUseCase(any()) } returns flowOf(Result.Success(coinDetails)) every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Success(coinChart)) every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Error(errorMessage)) @@ -146,18 +146,18 @@ class CoinDetailViewModelTest { @Test fun `When all use cases return success should have success UI state`() = runTest { // Arrange - val coinDetail = mockkClass(CoinDetail::class) + val coinDetails = mockkClass(CoinDetails::class) val coinChart = mockkClass(CoinChart::class) val isCoinFavourite = false - val expectedUiState = CoinDetailUiState.Success( - coinDetail = coinDetail, + val expectedUiState = DetailsUiState.Success( + coinDetails = coinDetails, coinChart = coinChart, chartPeriod = ChartPeriod.Day, isCoinFavourite = isCoinFavourite ) - every { getCoinDetailUseCase(any()) } returns flowOf(Result.Success(coinDetail)) + every { getCoinDetailsUseCase(any()) } returns flowOf(Result.Success(coinDetails)) every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Success(coinChart)) every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Success(isCoinFavourite)) @@ -169,89 +169,93 @@ class CoinDetailViewModelTest { } @Test - fun `When updating chart period UI state should update with new chart period value`() = runTest { - // Arrange - val coinDetail = mockkClass(CoinDetail::class) - val coinChart = mockkClass(CoinChart::class) - val isCoinFavourite = false + fun `When updating chart period UI state should update with new chart period value`() = + runTest { + // Arrange + val coinDetails = mockkClass(CoinDetails::class) + val coinChart = mockkClass(CoinChart::class) + val isCoinFavourite = false + + val expectedUiState = DetailsUiState.Success( + coinDetails = coinDetails, + coinChart = coinChart, + chartPeriod = ChartPeriod.Week, + isCoinFavourite = isCoinFavourite + ) - val expectedUiState = CoinDetailUiState.Success( - coinDetail = coinDetail, - coinChart = coinChart, - chartPeriod = ChartPeriod.Week, - isCoinFavourite = isCoinFavourite - ) + every { getCoinDetailsUseCase(any()) } returns flowOf(Result.Success(coinDetails)) + every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Success(coinChart)) + every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Success(isCoinFavourite)) - every { getCoinDetailUseCase(any()) } returns flowOf(Result.Success(coinDetail)) - every { getCoinChartUseCase(any(), any()) } returns flowOf(Result.Success(coinChart)) - every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Success(isCoinFavourite)) + // Act + viewModel.initialiseUiState() + viewModel.updateChartPeriod(ChartPeriod.Week) - // Act - viewModel.initialiseUiState() - viewModel.updateChartPeriod(ChartPeriod.Week) - - // Assert - assertThat(viewModel.uiState.value).isEqualTo(expectedUiState) - } + // Assert + assertThat(viewModel.uiState.value).isEqualTo(expectedUiState) + } @Test - fun `When toggle coin favourite returns success with un-favourited coin should favourite coin`() = runTest { - // Arrange - val coinId = "Qwsogvtv82FCd" - every { isCoinFavouriteUseCase(coinId = coinId) } returns flowOf(Result.Success(false)) - coEvery { insertFavouriteCoinUseCase(any()) } just Runs - - // Act - viewModel.toggleIsCoinFavourite() - - // Assert - coVerify { - insertFavouriteCoinUseCase( - FavouriteCoin( - id = coinId + fun `When toggle coin favourite returns success with un-favourited coin should favourite coin`() = + runTest { + // Arrange + val coinId = "Qwsogvtv82FCd" + every { isCoinFavouriteUseCase(coinId = coinId) } returns flowOf(Result.Success(false)) + coEvery { insertFavouriteCoinUseCase(any()) } just Runs + + // Act + viewModel.toggleIsCoinFavourite() + + // Assert + coVerify { + insertFavouriteCoinUseCase( + FavouriteCoin( + id = coinId + ) ) - ) + } } - } @Test - fun `When toggle coin favourite returns success with favourited coin should un-favourite coin`() = runTest { - // Arrange - val coinId = "Qwsogvtv82FCd" - every { isCoinFavouriteUseCase(coinId = coinId) } returns flowOf(Result.Success(true)) - coEvery { deleteFavouriteCoinUseCase(any()) } just Runs - - // Act - viewModel.toggleIsCoinFavourite() - - // Assert - coVerify { - deleteFavouriteCoinUseCase( - FavouriteCoin( - id = coinId + fun `When toggle coin favourite returns success with favourited coin should un-favourite coin`() = + runTest { + // Arrange + val coinId = "Qwsogvtv82FCd" + every { isCoinFavouriteUseCase(coinId = coinId) } returns flowOf(Result.Success(true)) + coEvery { deleteFavouriteCoinUseCase(any()) } just Runs + + // Act + viewModel.toggleIsCoinFavourite() + + // Assert + coVerify { + deleteFavouriteCoinUseCase( + FavouriteCoin( + id = coinId + ) ) - ) + } } - } @Test - fun `When toggle coin favourite returns error should not attempt to favourite or un-favourite coin`() = runTest { - // Arrange - every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Error("Error")) - coEvery { insertFavouriteCoinUseCase(any()) } just Runs - coEvery { deleteFavouriteCoinUseCase(any()) } just Runs - - // Act - viewModel.toggleIsCoinFavourite() - - // Assert - coVerify { - isCoinFavouriteUseCase(any()) - } - - coVerify(exactly = 0) { - insertFavouriteCoinUseCase(any()) - deleteFavouriteCoinUseCase(any()) + fun `When toggle coin favourite returns error should not attempt to favourite or un-favourite coin`() = + runTest { + // Arrange + every { isCoinFavouriteUseCase(any()) } returns flowOf(Result.Error("Error")) + coEvery { insertFavouriteCoinUseCase(any()) } just Runs + coEvery { deleteFavouriteCoinUseCase(any()) } just Runs + + // Act + viewModel.toggleIsCoinFavourite() + + // Assert + coVerify { + isCoinFavouriteUseCase(any()) + } + + coVerify(exactly = 0) { + insertFavouriteCoinUseCase(any()) + deleteFavouriteCoinUseCase(any()) + } } - } } diff --git a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModelTest.kt b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModelTest.kt index ac0ce31f..2244c640 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModelTest.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/list/CoinListViewModelTest.kt @@ -28,7 +28,7 @@ class CoinListViewModelTest { val mainDispatcherRule = MainDispatcherRule() // Class under test - private lateinit var viewModel: CoinListViewModel + private lateinit var viewModel: ListViewModel @RelaxedMockK private lateinit var getCoinsUseCase: GetCoinsUseCase @@ -40,7 +40,7 @@ class CoinListViewModelTest { fun setup() { MockKAnnotations.init(this) - viewModel = CoinListViewModel( + viewModel = ListViewModel( getCoinsUseCase = getCoinsUseCase, getFavouriteCoinsUseCase = getFavouriteCoinsUseCase ) @@ -54,7 +54,7 @@ class CoinListViewModelTest { @Test fun `When ViewModel is initialised should have loading UI state`() = runTest { // Arrange - val expectedUiState = CoinListUiState.Loading + val expectedUiState = ListUiState.Loading // Act @@ -66,7 +66,7 @@ class CoinListViewModelTest { fun `When coins returns error should have error UI state`() = runTest { // Arrange val errorMessage = "Coins error" - val expectedUiState = CoinListUiState.Error(errorMessage) + val expectedUiState = ListUiState.Error(errorMessage) every { getCoinsUseCase() } returns flowOf(Result.Error(errorMessage)) every { getFavouriteCoinsUseCase() } returns flowOf(Result.Success(emptyList())) @@ -82,7 +82,7 @@ class CoinListViewModelTest { fun `When favourite coins returns error should have error UI state`() = runTest { // Arrange val errorMessage = "Favourite coins error" - val expectedUiState = CoinListUiState.Error(errorMessage) + val expectedUiState = ListUiState.Error(errorMessage) every { getCoinsUseCase() } returns flowOf(Result.Success(emptyList())) every { getFavouriteCoinsUseCase() } returns flowOf(Result.Error(errorMessage)) @@ -148,7 +148,7 @@ class CoinListViewModelTest { else -> TimeOfDay.Evening } - val expectedUiState = CoinListUiState.Success( + val expectedUiState = ListUiState.Success( coins = coins, favouriteCoins = expectedFavouriteCoins, timeOfDay = expectedTimeOfDay diff --git a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModelTest.kt b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModelTest.kt index a1540e53..c5327c98 100644 --- a/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModelTest.kt +++ b/app/src/test/java/dev/shorthouse/coinwatch/ui/screen/search/CoinSearchViewModelTest.kt @@ -22,7 +22,7 @@ class CoinSearchViewModelTest { val mainDispatcherRule = MainDispatcherRule() // Class under test - private lateinit var viewModel: CoinSearchViewModel + private lateinit var viewModel: SearchViewModel @MockK private lateinit var getCoinSearchResultsUseCase: GetCoinSearchResultsUseCase @@ -31,7 +31,7 @@ class CoinSearchViewModelTest { fun setup() { MockKAnnotations.init(this) - viewModel = CoinSearchViewModel( + viewModel = SearchViewModel( getCoinSearchResultsUseCase = getCoinSearchResultsUseCase ) } @@ -44,7 +44,7 @@ class CoinSearchViewModelTest { @Test fun `When ViewModel is initialised should have loading UI state`() = runTest { // Arrange - val expectedUiState = CoinSearchUiState.Loading + val expectedUiState = SearchUiState.Loading // Act @@ -66,7 +66,7 @@ class CoinSearchViewModelTest { @Test fun `When search query is empty should return empty search results list`() = runTest { // Arrange - val expectedUiState = CoinSearchUiState.Success( + val expectedUiState = SearchUiState.Success( searchResults = persistentListOf(), queryHasNoResults = false ) @@ -86,7 +86,7 @@ class CoinSearchViewModelTest { val searchQuery = "bit" val errorMessage = "Unable to fetch coin search results" - val expectedUiState = CoinSearchUiState.Error( + val expectedUiState = SearchUiState.Error( message = errorMessage ) @@ -124,7 +124,7 @@ class CoinSearchViewModelTest { ) ) - val expectedUiState = CoinSearchUiState.Success( + val expectedUiState = SearchUiState.Success( searchResults = searchResults, queryHasNoResults = false ) @@ -164,7 +164,7 @@ class CoinSearchViewModelTest { val searchResults = persistentListOf() - val expectedUiState = CoinSearchUiState.Success( + val expectedUiState = SearchUiState.Success( searchResults = searchResults, queryHasNoResults = true )