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()
+ }
+}