diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5c43799f..1b0fc7ad 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -37,3 +37,11 @@ jobs: - name: Check code test coverage run: ./gradlew jvmTest -Pkover koverVerify + + - name: Upload JVM test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jvm-test-report + path: foliary/build/reports/tests/jvmTest/ + if-no-files-found: ignore diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 7f6e097d..3420b0f3 100644 --- a/foliary/src/commonMain/composeResources/values/strings.xml +++ b/foliary/src/commonMain/composeResources/values/strings.xml @@ -36,6 +36,7 @@ Today + Upcoming Profile @@ -44,6 +45,10 @@ Profile + + Upcoming + + Today Welcome back, %1$s diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt index 5b33f66c..4359c1e8 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainScreen.kt @@ -20,17 +20,21 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.composables.icons.lucide.CalendarCheck +import com.composables.icons.lucide.CalendarDays import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.User import dev.appoutlet.foliary.core.ui.component.layout.Screen import dev.appoutlet.foliary.feature.profile.ProfileScreen import dev.appoutlet.foliary.feature.today.TodayScreen +import dev.appoutlet.foliary.feature.upcoming.UpcomingScreen import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.main_nav_profile import foliary.foliary.generated.resources.main_nav_today +import foliary.foliary.generated.resources.main_nav_upcoming import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -41,64 +45,89 @@ fun MainScreen() { screenName = "MainScreen", viewModelProvider = { viewModel } ) { viewData: MainViewData -> - val itemColors = getItemColors() + val todayLazyListState = rememberLazyListState() + val upcomingLazyListState = rememberLazyListState() - val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo( - adaptiveInfo = currentWindowAdaptiveInfoV2(), - ) - - val (windowDecorationPadding, itemTopPadding) = remember(layoutType) { - when (layoutType) { - NavigationSuiteType.NavigationRail, - NavigationSuiteType.WideNavigationRailExpanded, - NavigationSuiteType.WideNavigationRailCollapsed -> getWindowDecorationPadding() to 16.dp - - else -> 0.dp to 0.dp + MainScreenNavigation( + selectedTab = viewData.selectedTab, + onTabSelect = viewModel::onTabSelected + ) { selectedTab -> + when (selectedTab) { + MainTab.Today -> TodayScreen(todayLazyListState) + MainTab.Upcoming -> UpcomingScreen(upcomingLazyListState) + MainTab.Profile -> ProfileScreen() } } + } +} - NavigationSuiteScaffold( - modifier = Modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surface), - layoutType = layoutType, - navigationSuiteItems = { - item( - modifier = Modifier.padding(top = windowDecorationPadding + itemTopPadding), - selected = viewData.selectedTab == MainTab.Today, - onClick = { viewModel.onTabSelected(MainTab.Today) }, - icon = { Icon(Lucide.CalendarCheck, contentDescription = null) }, - label = { Text(stringResource(Res.string.main_nav_today)) }, - colors = itemColors, - ) +@Composable +private fun MainScreenNavigation( + selectedTab: MainTab, + onTabSelect: (MainTab) -> Unit, + content: @Composable (MainTab) -> Unit +) { + val itemColors = getItemColors() - item( - modifier = Modifier.padding(top = itemTopPadding), - selected = viewData.selectedTab == MainTab.Profile, - onClick = { viewModel.onTabSelected(MainTab.Profile) }, - icon = { Icon(Lucide.User, contentDescription = null) }, - label = { Text(stringResource(Res.string.main_nav_profile)) }, - colors = itemColors, - ) - }, - navigationSuiteColors = NavigationSuiteDefaults.colors( - navigationBarContainerColor = MaterialTheme.colorScheme.surface, - navigationRailContainerColor = MaterialTheme.colorScheme.surface, - ), - ) { - val todayLazyListState = rememberLazyListState() + val layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo( + adaptiveInfo = currentWindowAdaptiveInfoV2(), + ) - Crossfade( - modifier = Modifier.fillMaxSize(), - targetState = viewData.selectedTab, - ) { selectedTab -> - when (selectedTab) { - MainTab.Today -> TodayScreen(todayLazyListState) - MainTab.Profile -> ProfileScreen() - } - } + val (windowDecorationPadding, itemTopPadding) = remember(layoutType) { + when (layoutType) { + NavigationSuiteType.NavigationRail, + NavigationSuiteType.WideNavigationRailExpanded, + NavigationSuiteType.WideNavigationRailCollapsed -> getWindowDecorationPadding() to 16.dp + + else -> 0.dp to 0.dp } } + + NavigationSuiteScaffold( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surface), + layoutType = layoutType, + navigationSuiteItems = { + item( + modifier = Modifier.testTag("MainScreen:TodayTab") + .padding(top = windowDecorationPadding + itemTopPadding), + selected = selectedTab == MainTab.Today, + onClick = { onTabSelect(MainTab.Today) }, + icon = { Icon(Lucide.CalendarCheck, contentDescription = null) }, + label = { Text(stringResource(Res.string.main_nav_today)) }, + colors = itemColors, + ) + + item( + modifier = Modifier.testTag("MainScreen:UpcomingTab").padding(top = itemTopPadding), + selected = selectedTab == MainTab.Upcoming, + onClick = { onTabSelect(MainTab.Upcoming) }, + icon = { Icon(Lucide.CalendarDays, contentDescription = null) }, + label = { Text(stringResource(Res.string.main_nav_upcoming)) }, + colors = itemColors, + ) + + item( + modifier = Modifier.testTag("MainScreen:ProfileTab").padding(top = itemTopPadding), + selected = selectedTab == MainTab.Profile, + onClick = { onTabSelect(MainTab.Profile) }, + icon = { Icon(Lucide.User, contentDescription = null) }, + label = { Text(stringResource(Res.string.main_nav_profile)) }, + colors = itemColors, + ) + }, + navigationSuiteColors = NavigationSuiteDefaults.colors( + navigationBarContainerColor = MaterialTheme.colorScheme.surface, + navigationRailContainerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Crossfade( + modifier = Modifier.fillMaxSize(), + targetState = selectedTab, + content = content + ) + } } @Composable diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainViewModel.kt index 1b9d6faa..7238a2a4 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/main/MainViewModel.kt @@ -18,7 +18,7 @@ data class MainViewData( ) enum class MainTab { - Today, Profile + Today, Upcoming, Profile } object MainAction : Action diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt new file mode 100644 index 00000000..d347660c --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt @@ -0,0 +1,39 @@ +package dev.appoutlet.foliary.feature.upcoming + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.upcoming_title +import org.jetbrains.compose.resources.stringResource + +@Composable +fun UpcomingScreen(lazyListState: LazyListState) { + LazyColumn( + modifier = Modifier + .testTag("UpcomingScreen") + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .fillMaxSize(), + state = lazyListState, + ) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(Res.string.upcoming_title), + style = MaterialTheme.typography.displaySmall + ) + } + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryOutlinedButtonTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryOutlinedButtonTest.kt index b76bf6ae..133e0f72 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryOutlinedButtonTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryOutlinedButtonTest.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import io.kotest.matchers.shouldBe import kotlin.test.Test diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt index d901aa92..cdb2f2be 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliaryPrimaryButtonTest.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import io.kotest.matchers.shouldBe import kotlin.test.Test diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt index 9702b781..d629684e 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/ui/component/button/FoliarySecondaryButtonTest.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import io.kotest.matchers.shouldBe import kotlin.test.Test diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainScreenTest.kt new file mode 100644 index 00000000..ac9b613e --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainScreenTest.kt @@ -0,0 +1,52 @@ +package dev.appoutlet.foliary.feature.main + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import dev.appoutlet.foliary.FoliaryKoinApplication +import dev.appoutlet.foliary.core.analytics.LocalAnalytics +import dev.appoutlet.foliary.core.analytics.MockAnalytics +import org.koin.core.context.stopKoin +import org.koin.plugin.module.dsl.startKoin +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class MainScreenTest { + private val mockAnalytics = MockAnalytics() + + @BeforeTest + fun setup() { + startKoin() + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun `should render bottom navigation with three tabs`() = runComposeUiTest { + setContent { + CompositionLocalProvider(LocalAnalytics provides mockAnalytics) { + MainScreen() + } + } + + waitUntilAtLeastOneExists(hasTestTag("TodayScreen")) + + onNodeWithTag("MainScreen:TodayTab").assertIsDisplayed() + + onNodeWithTag("MainScreen:UpcomingTab").assertIsDisplayed().performClick() + waitUntilAtLeastOneExists(hasTestTag("UpcomingScreen")) + + onNodeWithTag("MainScreen:ProfileTab").assertIsDisplayed().performClick() + waitUntilAtLeastOneExists(hasTestTag("ProfileScreen")) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainViewModelTest.kt new file mode 100644 index 00000000..16c05c74 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/main/MainViewModelTest.kt @@ -0,0 +1,34 @@ +package dev.appoutlet.foliary.feature.main + +import dev.appoutlet.foliary.core.testing.ViewModelTest +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class MainViewModelTest : ViewModelTest() { + override fun createViewModel() = MainViewModel() + + @Test + fun `should select today tab by default`() { + test { + currentState.selectedTab shouldBe MainTab.Today + } + } + + @Test + fun `should switch to upcoming tab`() { + test { + viewModel.onTabSelected(MainTab.Upcoming) + + expectState { copy(selectedTab = MainTab.Upcoming) } + } + } + + @Test + fun `should switch to profile tab`() { + test { + viewModel.onTabSelected(MainTab.Profile) + + expectState { copy(selectedTab = MainTab.Profile) } + } + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/AuthenticatedTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/AuthenticatedTest.kt index 1cef7993..205b6d8e 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/AuthenticatedTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/AuthenticatedTest.kt @@ -3,7 +3,7 @@ package dev.appoutlet.foliary.feature.signin.composable import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import dev.appoutlet.foliary.feature.signin.SignInViewData import kotlin.test.Test diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/MagicLinkSentTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/MagicLinkSentTest.kt index 6d29badb..c7c4ff5e 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/MagicLinkSentTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/signin/composable/MagicLinkSentTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import dev.appoutlet.foliary.feature.signin.SignInEvent import dev.appoutlet.foliary.feature.signin.SignInViewData import io.kotest.matchers.shouldBe diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreenTest.kt new file mode 100644 index 00000000..9b5ad5fb --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreenTest.kt @@ -0,0 +1,23 @@ +package dev.appoutlet.foliary.feature.upcoming + +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import foliary.foliary.generated.resources.Res +import foliary.foliary.generated.resources.upcoming_title +import org.jetbrains.compose.resources.getString +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class UpcomingScreenTest { + @Test + fun `should render upcoming title`() = runComposeUiTest { + setContent { + UpcomingScreen(lazyListState = rememberLazyListState()) + } + + onNodeWithText(getString(Res.string.upcoming_title)).assertIsDisplayed() + } +}