Skip to content

Commit c8aeb64

Browse files
authored
Merge pull request #303 from joreilly/nested_presenter
CMP adaptive UI udpates + nested circuit presenter
2 parents ae0c938 + 28cef2f commit c8aeb64

File tree

9 files changed

+208
-25
lines changed

9 files changed

+208
-25
lines changed

androidApp/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ dependencies {
113113
implementation(libs.androidx.compose.ui.tooling)
114114
implementation(libs.androidx.navigation.compose)
115115

116-
testImplementation("junit:junit:4.13.2")
117-
androidTestImplementation("androidx.test:runner:1.6.2")
116+
testImplementation(libs.junit)
117+
androidTestImplementation(libs.androidx.runner)
118118

119119
implementation(projects.common)
120120
}

common/src/commonMain/kotlin/dev/johnoreilly/common/networklist/NetworkListPresenter.kt

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ package dev.johnoreilly.common.networklist
22

33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.collectAsState
5+
import androidx.compose.runtime.derivedStateOf
56
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.setValue
610
import com.slack.circuit.codegen.annotations.CircuitInject
11+
import com.slack.circuit.retained.rememberRetained
712
import com.slack.circuit.runtime.Navigator
813
import com.slack.circuit.runtime.presenter.Presenter
914
import dev.johnoreilly.common.getCountryName
1015
import dev.johnoreilly.common.repository.CityBikesRepository
1116
import dev.johnoreilly.common.screens.NetworkListScreen
1217
import dev.johnoreilly.common.screens.StationListScreen
18+
import dev.johnoreilly.common.stationlist.StationListPresenter
1319
import me.tatarka.inject.annotations.Assisted
1420
import me.tatarka.inject.annotations.Inject
1521
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
@@ -19,16 +25,37 @@ import software.amazon.lastmile.kotlin.inject.anvil.AppScope
1925
class NetworkListPresenter(
2026
@Assisted private val screen: NetworkListScreen,
2127
@Assisted private val navigator: Navigator,
22-
private val cityBikesRepository: CityBikesRepository
28+
private val cityBikesRepository: CityBikesRepository,
2329
) : Presenter<NetworkListScreen.State> {
30+
2431
@Composable
2532
override fun present(): NetworkListScreen.State {
33+
val countryName = remember(screen) { getCountryName(screen.countryCode) }
2634
val groupedNetworkList by cityBikesRepository.groupedNetworkList.collectAsState()
27-
val networkList = groupedNetworkList[screen.countryCode]?.sortedBy { it.city } ?: emptyList()
28-
val oountryName = getCountryName(screen.countryCode)
29-
return NetworkListScreen.State(screen.countryCode, oountryName, networkList) { event ->
35+
val networkList by remember {
36+
derivedStateOf { groupedNetworkList[screen.countryCode]?.sortedBy { it.city } ?: emptyList() }
37+
}
38+
39+
var selectedNetworkId by rememberRetained { mutableStateOf<String?>(null) }
40+
41+
val stationListPresenter = remember(selectedNetworkId) {
42+
selectedNetworkId?.let { networkId ->
43+
StationListPresenter(StationListScreen(networkId), navigator, cityBikesRepository)
44+
}
45+
}
46+
47+
val stationListState = stationListPresenter?.present()
48+
49+
return NetworkListScreen.State(
50+
countryCode = screen.countryCode,
51+
countryName = countryName,
52+
networkList = networkList,
53+
selectedNetworkId = selectedNetworkId,
54+
stationListState = stationListState,
55+
) { event ->
3056
when (event) {
3157
is NetworkListScreen.Event.NetworkClicked -> navigator.goTo(StationListScreen(event.networkId))
58+
is NetworkListScreen.Event.SelectNetwork -> selectedNetworkId = event.networkId
3259
NetworkListScreen.Event.BackClicked -> navigator.pop()
3360
}
3461
}

common/src/commonMain/kotlin/dev/johnoreilly/common/networklist/NetworkListUI.kt

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
package dev.johnoreilly.common.networklist
44

55
import androidx.compose.foundation.clickable
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.Column
68
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.fillMaxSize
710
import androidx.compose.foundation.layout.fillMaxWidth
811
import androidx.compose.foundation.layout.padding
912
import androidx.compose.foundation.lazy.LazyColumn
1013
import androidx.compose.foundation.lazy.items
1114
import androidx.compose.material.icons.Icons
1215
import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+
import androidx.compose.material3.CircularProgressIndicator
1317
import androidx.compose.material3.ExperimentalMaterial3Api
1418
import androidx.compose.material3.Icon
1519
import androidx.compose.material3.IconButton
@@ -25,8 +29,11 @@ import androidx.compose.ui.unit.dp
2529
import com.slack.circuit.codegen.annotations.CircuitInject
2630
import dev.johnoreilly.common.model.Network
2731
import dev.johnoreilly.common.screens.NetworkListScreen
32+
import dev.johnoreilly.common.stationlist.StationListContent
33+
import dev.johnoreilly.common.ui.AdaptiveLayout
2834
import software.amazon.lastmile.kotlin.inject.anvil.AppScope
2935

36+
3037
@CircuitInject(NetworkListScreen::class, AppScope::class)
3138
@Composable
3239
fun NetworkListUi(state: NetworkListScreen.State, modifier: Modifier = Modifier) {
@@ -43,12 +50,82 @@ fun NetworkListUi(state: NetworkListScreen.State, modifier: Modifier = Modifier)
4350
)
4451
}
4552
) { innerPadding ->
46-
LazyColumn(modifier = Modifier.padding(innerPadding)) {
47-
items(state.networkList) { network ->
48-
NetworkView(network) {
49-
state.eventSink(NetworkListScreen.Event.NetworkClicked(network.id))
53+
AdaptiveLayout(
54+
modifier = Modifier.fillMaxSize().padding(innerPadding),
55+
compactContent = {
56+
// Show only network list on narrow screens
57+
NetworkList(
58+
networkList = state.networkList,
59+
onNetworkSelected = { networkId ->
60+
state.eventSink(NetworkListScreen.Event.NetworkClicked(networkId))
61+
}
62+
)
63+
},
64+
expandedContent = {
65+
// Show network list and station list side by side on wider screens
66+
Row(modifier = Modifier.fillMaxSize()) {
67+
// Network list (left side)
68+
NetworkList(
69+
modifier = Modifier.weight(0.4f),
70+
networkList = state.networkList,
71+
onNetworkSelected = { networkId ->
72+
state.eventSink(NetworkListScreen.Event.SelectNetwork(networkId))
73+
}
74+
)
75+
76+
// Station list (right side)
77+
Box(
78+
modifier = Modifier.weight(0.6f)
79+
) {
80+
state.stationListState?.let { stationListState ->
81+
// Show actual station list for the selected network
82+
Column(modifier = Modifier.fillMaxSize()) {
83+
Text(
84+
text = stationListState.networkId,
85+
style = MaterialTheme.typography.headlineSmall
86+
)
87+
88+
if (stationListState.isLoadingStations) {
89+
Box(
90+
modifier = Modifier.fillMaxSize(),
91+
contentAlignment = Alignment.Center
92+
) {
93+
CircularProgressIndicator()
94+
}
95+
} else {
96+
StationListContent(stationListState.stationList)
97+
}
98+
}
99+
} ?: run {
100+
// Placeholder when no network is selected
101+
Box(
102+
modifier = Modifier.fillMaxSize(),
103+
contentAlignment = Alignment.Center
104+
) {
105+
Text(
106+
"Select a network to view stations",
107+
style = MaterialTheme.typography.bodyLarge
108+
)
109+
}
110+
}
111+
}
50112
}
51113
}
114+
)
115+
}
116+
}
117+
118+
@Composable
119+
fun NetworkList(
120+
networkList: List<Network>,
121+
onNetworkSelected: (String) -> Unit,
122+
modifier: Modifier = Modifier
123+
) {
124+
LazyColumn(modifier = modifier) {
125+
items(networkList) { network ->
126+
NetworkView(network) {
127+
onNetworkSelected(network.id)
128+
}
52129
}
53130
}
54131
}

common/src/commonMain/kotlin/dev/johnoreilly/common/screens/Screens.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ data class NetworkListScreen(val countryCode: String) : Screen {
3131
val countryCode: String,
3232
val countryName: String,
3333
val networkList: List<Network>,
34+
val selectedNetworkId: String? = null,
35+
val stationListState: StationListScreen.State?,
3436
val eventSink: (Event) -> Unit
3537
) : CircuitUiState
3638

3739
sealed class Event : CircuitUiEvent {
3840
data class NetworkClicked(val networkId: String) : Event()
41+
data class SelectNetwork(val networkId: String) : Event()
3942
data object BackClicked : Event()
4043
}
4144
}
@@ -45,6 +48,7 @@ data class StationListScreen(val networkId: String) : Screen {
4548
data class State(
4649
val networkId: String,
4750
val stationList: List<Station>,
51+
val isLoadingStations: Boolean,
4852
val eventSink: (Event) -> Unit
4953
) : CircuitUiState
5054

common/src/commonMain/kotlin/dev/johnoreilly/common/stationlist/StationListPresenter.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package dev.johnoreilly.common.stationlist
22

33
import androidx.compose.runtime.Composable
4-
import androidx.compose.runtime.collectAsState
54
import androidx.compose.runtime.getValue
65
import androidx.compose.runtime.remember
76
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -23,12 +22,17 @@ class StationListPresenter(
2322
) : Presenter<StationListScreen.State> {
2423
@Composable
2524
override fun present(): StationListScreen.State {
26-
val networkFlow = remember { cityBikesRepository.pollNetworkUpdates(screen.networkId) }
25+
val networkFlow = remember(screen) { cityBikesRepository.pollNetworkUpdates(screen.networkId) }
2726
val stationList by networkFlow.collectAsStateWithLifecycle(emptyList())
28-
return StationListScreen.State(screen.networkId, stationList) { event ->
29-
when (event) {
30-
StationListScreen.Event.BackClicked -> navigator.pop()
31-
}
27+
return StationListScreen.State(
28+
screen.networkId,
29+
stationList,
30+
stationList.isEmpty()
31+
) {
32+
event ->
33+
when (event) {
34+
StationListScreen.Event.BackClicked -> navigator.pop()
35+
}
3236
}
3337
}
3438
}

common/src/commonMain/kotlin/dev/johnoreilly/common/stationlist/StationListUI.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,22 @@ fun StationListUI(state: StationListScreen.State, modifier: Modifier = Modifier)
5858
)
5959
}
6060
) { paddingValues ->
61-
LazyColumn(Modifier.padding(paddingValues)) {
62-
items(state.stationList.sortedBy { it.name }) { station ->
63-
StationView(station)
64-
}
61+
Column(Modifier.padding(paddingValues)) {
62+
StationListContent(state.stationList)
6563
}
6664
}
6765
}
6866

6967

68+
@Composable
69+
fun StationListContent(stationList: List<Station>) {
70+
LazyColumn {
71+
items(stationList.sortedBy { it.name }) { station ->
72+
StationView(station)
73+
}
74+
}
75+
}
76+
7077
@Composable
7178
fun StationView(station: Station) {
7279

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package dev.johnoreilly.common.ui
2+
3+
import androidx.compose.foundation.layout.BoxWithConstraints
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.platform.LocalDensity
8+
import androidx.compose.ui.unit.Dp
9+
import androidx.compose.ui.unit.dp
10+
11+
/**
12+
* Window size classes for adaptive layouts
13+
*/
14+
enum class WindowWidthSizeClass {
15+
Compact,
16+
Medium,
17+
Expanded
18+
}
19+
20+
/**
21+
* Remembers the [WindowWidthSizeClass] based on the window width
22+
*/
23+
@Composable
24+
fun rememberWindowWidthSizeClass(windowWidth: Dp): WindowWidthSizeClass {
25+
val windowWidthSizeClass = remember(windowWidth) {
26+
when {
27+
windowWidth < 600.dp -> WindowWidthSizeClass.Compact
28+
windowWidth < 840.dp -> WindowWidthSizeClass.Medium
29+
else -> WindowWidthSizeClass.Expanded
30+
}
31+
}
32+
return windowWidthSizeClass
33+
}
34+
35+
/**
36+
* Remembers the [WindowWidthSizeClass] based on the window width in pixels
37+
*/
38+
@Composable
39+
fun rememberWindowWidthSizeClass(windowWidthPx: Int): WindowWidthSizeClass {
40+
val density = LocalDensity.current
41+
val windowWidth = with(density) { windowWidthPx.toDp() }
42+
return rememberWindowWidthSizeClass(windowWidth)
43+
}
44+
45+
/**
46+
* A composable that adapts its content based on the available width
47+
*/
48+
@Composable
49+
fun AdaptiveLayout(
50+
modifier: Modifier = Modifier,
51+
compactContent: @Composable () -> Unit,
52+
expandedContent: @Composable () -> Unit
53+
) {
54+
BoxWithConstraints(modifier = modifier) {
55+
val windowWidthSizeClass = rememberWindowWidthSizeClass(maxWidth)
56+
57+
when (windowWidthSizeClass) {
58+
WindowWidthSizeClass.Compact -> compactContent()
59+
WindowWidthSizeClass.Medium, WindowWidthSizeClass.Expanded -> expandedContent()
60+
}
61+
}
62+
}

gradle/libs.versions.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
kotlin = "2.2.0"
33
ksp = "2.2.0-2.0.2"
44

5-
androidGradlePlugin = "8.11.1"
5+
androidGradlePlugin = "8.12.0"
66
androidxActivity = "1.10.1"
77
androidxComposeBom = "2025.07.00"
88
androidxLifecycle = "2.9.2"
9-
androidxNavigationCompose = "2.9.2"
9+
androidxNavigationCompose = "2.9.3"
1010
androidxRoom = "2.7.2"
11-
circuit = "0.29.1"
11+
circuit = "0.30.0"
1212
composeLifecyleRuntime="2.9.1"
1313
compose-multiplatform = "1.8.2"
1414
composeAdaptiveLayout = "1.1.2"
@@ -19,8 +19,9 @@ kmpObservableViewModel = "1.0.0-BETA-12"
1919
kotlin-inject-anvil = "0.1.6"
2020
kotlininject = "0.8.0"
2121
kotlinxSerialization = "1.9.0"
22-
ktor = "3.2.2"
22+
ktor = "3.2.3"
2323
okhttp = "5.1.0"
24+
runner = "1.7.0"
2425
slf4j = "2.0.17"
2526
slf4jAndroid = "2.0.17-0"
2627
sqlite = "2.5.2"
@@ -31,6 +32,7 @@ compileSdk = "36"
3132

3233

3334
[libraries]
35+
androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }
3436
kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
3537
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
3638
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
44
networkTimeout=10000
55
validateDistributionUrl=true
66
zipStoreBase=GRADLE_USER_HOME

0 commit comments

Comments
 (0)