diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index 1418c1f..2a52c64 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -48,8 +48,9 @@ import moe.lava.banksia.native.maps.getScreenHeight import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.my_location_24 import moe.lava.banksia.ui.BanksiaViewModel +import moe.lava.banksia.ui.InfoPanel import moe.lava.banksia.ui.Searcher -import moe.lava.banksia.ui.StopInfoPanel +import moe.lava.banksia.ui.state.InfoPanelState import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview import kotlin.coroutines.cancellation.CancellationException @@ -72,6 +73,7 @@ fun App( scope.launch { locationTracker.startTracking() } val state by viewModel.state.collectAsStateWithLifecycle() + val infoState by viewModel.infoState.collectAsStateWithLifecycle() val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( @@ -90,9 +92,8 @@ fun App( (getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0) } else 0 - LaunchedEffect(state.stopState) { - val isShown = state.stopState != null - if (isShown) + LaunchedEffect(infoState) { + if (infoState !is InfoPanelState.None) scope.launch { scaffoldState.bottomSheetState.partialExpand() } else scope.launch { scaffoldState.bottomSheetState.hide() } @@ -111,9 +112,11 @@ fun App( sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier, modifier = Modifier.fillMaxSize(), sheetContent = { - state.stopState?.let { stopState -> - StopInfoPanel(stopState) { peekHeight = it } - } + InfoPanel( + state = infoState, + onEvent = viewModel::handleEvent, + onPeekHeightChange = { peekHeight = it }, + ) }, sheetDragHandle = { val density = LocalDensity.current @@ -141,7 +144,6 @@ fun App( polylines = state.polylines, ) Searcher( - selectedRoute = state.routeState?.route, routes = state.routes, expanded = searchExpandedState, onExpandedChange = { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt index 147054d..7e4fdb8 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt @@ -42,9 +42,10 @@ fun PtvRouteType.getProperties(): RouteTypeProperties { } return RouteTypeProperties(colour, drawable, background, icon) } + @Composable -fun PtvRouteType.ComposableIcon() { - val properties = this.getProperties() +fun ComposableRouteIcon(routeType: PtvRouteType) { + val properties = routeType.getProperties() Image( painter = painterResource(properties.icon), contentDescription = null, @@ -54,3 +55,6 @@ fun PtvRouteType.ComposableIcon() { } ) } + +@Composable +inline fun PtvRouteType.ComposableIcon() = ComposableRouteIcon(this) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt index 69c2199..8ab10f9 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt @@ -4,10 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.icerock.moko.geo.LocationTracker import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn @@ -27,33 +25,24 @@ import moe.lava.banksia.native.maps.Marker import moe.lava.banksia.native.maps.MarkerType import moe.lava.banksia.native.maps.Point import moe.lava.banksia.native.maps.Polyline +import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue.Companion.box -data class RouteState( - val route: PtvRoute, - val stops: List? = null, -) - -data class StopState( - val stop: PtvStop, -// val departures: List? = null, - val departures: List>? = null, -) +sealed class BanksiaEvent {} data class BanksiaViewState( - val routeState: RouteState? = null, - val stopState: StopState? = null, - val routes: List = listOf(), - val markers: List = listOf(), val polylines: List = listOf(), ) class BanksiaViewModel : ViewModel() { private val iState = MutableStateFlow(BanksiaViewState()) - val state: StateFlow = iState.asStateFlow() + val state = iState.asStateFlow() + + private val iInfoState = MutableStateFlow(InfoPanelState.None) + val infoState = iInfoState.asStateFlow() private val ptvService = PtvService() private var locationTrackerJob: Job? = null @@ -68,6 +57,8 @@ class BanksiaViewModel : ViewModel() { } } + fun handleEvent(event: BanksiaEvent) {} + fun bindTracker(locationTracker: LocationTracker) { locationTrackerJob = locationTracker.getLocationsFlow() .onEach { lastKnownLocation = Point(it.latitude, it.longitude) } @@ -98,36 +89,47 @@ class BanksiaViewModel : ViewModel() { iState.update { it.copy(routes = routes) } } - fun switchRoute(newRoute: PtvRoute?) { - val routeState = newRoute?.let { RouteState(it) } - if (iState.value.routeState == routeState) - return - + fun switchRoute(route: PtvRoute?) { iState.update { it.copy( - routeState = routeState, - stopState = null, markers = listOf(), polylines = listOf(), ) } + iInfoState.update { + if (route == null) + InfoPanelState.None + else + InfoPanelState.Route( + name = route.routeName, + type = route.routeType, + ) + } - if (routeState != null) { - viewModelScope.launch { - async { buildPolylines() } - async { buildMarkers() } - } + if (route != null) { + viewModelScope.launch { buildPolylines(route) } + viewModelScope.launch { buildStops(route) } +// viewModelScope.launch { buildDepartures() } +// viewModelScope.launch { buildRuns() } } } // [TODO]: Cleanup suspend fun switchStop(stop: PtvStop?) { - iState.update { state -> - state.copy(stopState = stop?.let { StopState(it) }) - } - - if (stop == null) + if (stop == null) { + iInfoState.update { InfoPanelState.None } return + } + val split = stop.stopName.split("/") + val name = split[0] + val subname = split.getOrNull(1) + iInfoState.update { + InfoPanelState.Stop( + id = stop.stopId, + name = name, + subname = subname, + ) + } val res = ptvService.departures(stop.routeType, stop.stopId) // Map< @@ -155,18 +157,19 @@ class BanksiaViewModel : ViewModel() { } val departures = timetable.values.sortedBy { it.first }.map { (name, list) -> if (list.isEmpty()) - Pair(name, "No departures") + InfoPanelState.Stop.Departure(name, "No departures") else - Pair(name, list.joinToString(" | ")) + InfoPanelState.Stop.Departure(name, list.joinToString(" | ")) } - iState.update { - it.copy(stopState = it.stopState?.copy(departures = departures)) + iInfoState.update { + if (it !is InfoPanelState.Stop) + it + else + it.copy(departures = departures) } } - private suspend fun buildPolylines() { - val route = iState.value.routeState?.route ?: return - + private suspend fun buildPolylines(route: PtvRoute) { val routeWithGeo = if (route.geopath.isEmpty()) ptvService.route(route.routeId, true) else @@ -198,9 +201,25 @@ class BanksiaViewModel : ViewModel() { newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } } - private suspend fun buildMarkers() { - val route = iState.value.routeState?.route ?: return +// private suspend fun buildDepartures(route: PtvRoute) { +// val directions = ptvService.directionsByRoute(route.routeId) +// +// iState.update { +// it.copy(routeState = it.routeState?.copy(directions = directions)) +// } +// } +// +// private suspend fun buildRuns() { +// val route = iState.value.routeState?.route ?: return +// +// val directions = ptvService.directionsByRoute(route.routeId) +// +// iState.update { +// it.copy(routeState = it.routeState?.copy(directions = directions)) +// } +// } + private suspend fun buildStops(route: PtvRoute) { val stops = ptvService.stopsByRoute(route.routeId, route.routeType) val markers = mutableListOf() val colour = route.routeType.getProperties().colour @@ -222,12 +241,7 @@ class BanksiaViewModel : ViewModel() { } } - iState.update { - it.copy( - routeState = it.routeState?.copy(stops = stops), - markers = markers - ) - } + iState.update { it.copy(markers = markers) } } private fun buildBounds(points: List): CameraPositionBounds { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt similarity index 50% rename from composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt rename to composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt index 491f21f..082edac 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt @@ -29,14 +29,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp +import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon +import moe.lava.banksia.ui.state.InfoPanelState @Composable -fun StopInfoPanel( - stopState: StopState, +fun InfoPanel( + state: InfoPanelState, + onEvent: (BanksiaEvent) -> Unit, onPeekHeightChange: (Dp) -> Unit, ) { + if (state is InfoPanelState.None) + return + val localDensity = LocalDensity.current - val (stop, departures) = stopState Column( Modifier @@ -49,36 +54,13 @@ fun StopInfoPanel( } ) { Box { - Column(Modifier.fillMaxWidth()) { - val split = stop.stopName.split("/") - val mainName = split[0] - val subName = split.getOrNull(1) - Text( - mainName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Start - ) - if (subName != null) - Text( - "/ $subName", - modifier = Modifier.padding(start = 5.dp), - style = MaterialTheme.typography.titleSmall, - color = Color.Gray, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Start - ) - departures?.let { - Spacer(Modifier.height(5.dp)) - it.forEach { (name, formatted) -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp)) - } - } - } + when (state) { + is InfoPanelState.Route -> RouteInfoPanel(state, onEvent) + is InfoPanelState.Stop -> StopInfoPanel(state, onEvent) + else -> throw UnsupportedOperationException() } - if (departures == null) + + if (state.loading) CircularProgressIndicator( modifier = Modifier.width(32.dp).align(Alignment.CenterEnd) ) @@ -86,3 +68,55 @@ fun StopInfoPanel( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent)) } } + +@Composable +private fun RouteInfoPanel( + state: InfoPanelState.Route, + onEvent: (BanksiaEvent) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + Row { + ComposableRouteIcon(state.type) + Text( + state.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Start + ) + } + } +} + +@Composable +private fun StopInfoPanel( + state: InfoPanelState.Stop, + onEvent: (BanksiaEvent) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + Text( + state.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Start + ) + state.subname?.let { + Text( + "/ $it", + modifier = Modifier.padding(start = 5.dp), + style = MaterialTheme.typography.titleSmall, + color = Color.Gray, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Start + ) + } + state.departures?.let { + Spacer(Modifier.height(5.dp)) + it.forEach { (name, formatted) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp)) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt index 77a8a93..d61a528 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt @@ -5,51 +5,38 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import moe.lava.banksia.api.ptv.structures.ComposableIcon import moe.lava.banksia.api.ptv.structures.PtvRoute -import kotlin.coroutines.cancellation.CancellationException import kotlin.math.pow -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Searcher( - selectedRoute: PtvRoute?, routes: List, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, @@ -84,21 +71,7 @@ fun Searcher( null -> PaddingValues() } - PredictiveBackHandler(enabled = selectedRoute != null) { progress -> - try { - progress.collect { backEvent -> - backProgress = backEvent.progress - backEdgeIsLeft = backEvent.swipeEdge == 0 - } - backProgress = 1f - onRouteChange(null) - } catch (_: CancellationException) { - backProgress = 0f - } - backEdgeIsLeft = null - } SearchBarDefaults.InputField( - enabled = selectedRoute == null, modifier = Modifier .alpha(1f - routeInfoOpacity) .padding(horizontal = 20.dp - animatedPadding), @@ -117,11 +90,6 @@ fun Searcher( ) } ) - LaunchedEffect(selectedRoute) { - backProgress = if (selectedRoute != null) 0f else 1f; - } - if (selectedRoute != null) - RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, selectedRoute) }, expanded = expanded, onExpandedChange = onExpandedChange, @@ -156,47 +124,3 @@ fun Searcher( } } } - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun RouteInfo( - routeInfoOpacity: Float, - slidePadding: PaddingValues, - onRouteChange: (PtvRoute?) -> Unit, - route: PtvRoute -) { - Box( - modifier = Modifier - .alpha(routeInfoOpacity) - .sizeIn( - minHeight = SearchBarDefaults.InputFieldHeight, - maxHeight = SearchBarDefaults.InputFieldHeight, - ) - .fillMaxWidth() - .padding(slidePadding) - ) { - IconButton( - modifier = Modifier.align(Alignment.CenterStart), - onClick = { - onRouteChange(null) - } - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Clear route") - } - Row( - modifier = Modifier - .padding(horizontal = 50.dp) - .align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - ) { - route.routeType.ComposableIcon() - Spacer(Modifier.width(15.dp)) - Text( - route.routeNumber.ifEmpty { route.routeName }, - style = MaterialTheme.typography.headlineSmall, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt new file mode 100644 index 0000000..bc005bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt @@ -0,0 +1,30 @@ +package moe.lava.banksia.ui.state + +import moe.lava.banksia.api.ptv.structures.PtvRouteType + +sealed class InfoPanelState { + abstract val loading: Boolean + + data object None : InfoPanelState() { + override val loading = false + } + + data class Route( + val name: String, + val type: PtvRouteType, + ) : InfoPanelState() { + override val loading = false + } + + data class Stop( + val id: Int, + val name: String, + val subname: String? = null, + val departures: List? = null, + ) : InfoPanelState() { + override val loading: Boolean + get() = departures == null + + data class Departure(val directionName: String, val formattedTimes: String) + } +} \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7bb1c63..8f14064 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) } iosMain.dependencies { diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt new file mode 100644 index 0000000..b35d0b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt @@ -0,0 +1,28 @@ +package moe.lava.banksia.api.ptv.structures + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +data class PtvVehiclePosition( + val latitude: Double, + val longitude: Double, + val easting: Double?, + val northing: Double?, + val direction: String?, + val bearing: Double?, + val supplier: String?, + @SerialName("datetime_utc") val datetimeUtc: Instant?, + @SerialName("expiry_time") val expiryTime: Instant?, +) + +@Serializable +data class PtvRun( + @SerialName("run_ref") val runRef: String, + @SerialName("route_id") val routeId: Int, + @SerialName("route_type") val routeType: PtvRouteType, + @SerialName("final_stop_id") val finalStopId: Int, + @SerialName("destination_name") val destinationName: String, + @SerialName("direction_id") val directionId: Int, + @SerialName("status") val status: String, +)