diff --git a/driver-app/app/build.gradle.kts b/driver-app/app/build.gradle.kts index f11f4f907..da91d87a8 100644 --- a/driver-app/app/build.gradle.kts +++ b/driver-app/app/build.gradle.kts @@ -24,7 +24,8 @@ android { buildTypes { debug { - buildConfigField("String", "BASE_URL", "\"https://prima-staging.motis-project.de\"") + //buildConfigField("String", "BASE_URL", "\"https://prima-staging.motis-project.de\"") + buildConfigField("String", "BASE_URL", "\"http://82.165.178.73:7777\"") } release { isMinifyEnabled = false diff --git a/driver-app/app/src/main/java/de/motis/prima/data/DataRepository.kt b/driver-app/app/src/main/java/de/motis/prima/data/DataRepository.kt index 12d78f3cc..22885b440 100644 --- a/driver-app/app/src/main/java/de/motis/prima/data/DataRepository.kt +++ b/driver-app/app/src/main/java/de/motis/prima/data/DataRepository.kt @@ -5,9 +5,9 @@ import android.content.res.Configuration import android.util.Log import com.google.firebase.messaging.FirebaseMessaging import de.motis.prima.services.ApiService +import de.motis.prima.services.Leg import de.motis.prima.services.Tour import de.motis.prima.services.Vehicle -import de.motis.prima.ui.TimeBlock import io.realm.kotlin.query.RealmResults import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -63,6 +63,9 @@ class DataRepository @Inject constructor( private val _networkError = MutableStateFlow(false) val networkError = _networkError.asStateFlow() + private val _updateError = MutableStateFlow(false) + val updateError = _updateError.asStateFlow() + val selectedVehicle: Flow = dataStoreManager.selectedVehicleFlow private var _vehicleId = 0 @@ -71,6 +74,16 @@ class DataRepository @Inject constructor( private val _markedTour = MutableStateFlow(-1) val markedTour: StateFlow = _markedTour.asStateFlow() + private val _ptLegs = MutableStateFlow>(hashMapOf()) + val ptLegs: StateFlow> = _ptLegs.asStateFlow() + + private val _updateRequestIDs = MutableStateFlow(emptySet()) + private val updateRequestIDs = _updateRequestIDs.asStateFlow() + + fun setUpdateIds(ids: Set) { + _updateRequestIDs.value = ids + } + private val _eventObjectGroups = MutableStateFlow>(emptyList()) val eventObjectGroups: StateFlow> = _eventObjectGroups.asStateFlow() @@ -429,19 +442,8 @@ class DataRepository @Inject constructor( return tourStore.getTour(id) } - fun hasPendingValidations(tourId: Int): Boolean { - for (id in tourStore.getPickupRequestIDs(tourId)) { - if (_pendingValidationTickets.value.find { e -> e.requestId == id } != null ) { - return true - } - } - return false - } - - fun hasInvalidatedTickets(tourId: Int): Boolean { - val pickupEvents = tourStore.getEventsForTour(tourId).filter { e -> e.isPickup } - val invalidated = pickupEvents.filter { e -> e.ticketChecked.not() } - return invalidated.isNotEmpty() + fun getEvent(id: Int): EventObject? { + return tourStore.getEvent(id) } fun getTourSpecialInfo(tourId: Int): TourSpecialInfo { @@ -472,4 +474,55 @@ class DataRepository @Inject constructor( fun removeMarker() { _markedTour.value = -1 } + + fun stopPolling() { + _updateRequestIDs.value = emptySet() + _realTimePolling.value = false + } + + private val _realTimePolling = MutableStateFlow(false) + + private suspend fun updateLegs(requestId: Int): Int { + var error: Int + try { + val res = apiService.getItinerary(requestId) + if (res.isSuccessful) { + val leg = res.body() + _ptLegs.value = HashMap(_ptLegs.value).apply { + put(requestId, leg) + } + return 0 + } + error = -1 + } catch (e: Exception) { + Log.e("error", "${e.message}") + error = -2 + } + _ptLegs.value.remove(requestId) // invalidate previously fetched data + return error + } + + fun getItinerary(requestId: Int) { + CoroutineScope(Dispatchers.IO).launch { + updateLegs(requestId) + } + } + + private fun updateItinerariesFlow() = flow { + while (_realTimePolling.value) { + for (requestId in updateRequestIDs.value) { + emit(updateLegs(requestId)) + } + delay(120000) + } + }.flowOn(Dispatchers.IO) + + fun initRealTimePolling() { + _realTimePolling.value = true + CoroutineScope(Dispatchers.IO).launch { + updateItinerariesFlow().collect { e -> + _updateError.value = e == -2 + } + } + } } diff --git a/driver-app/app/src/main/java/de/motis/prima/data/TourStore.kt b/driver-app/app/src/main/java/de/motis/prima/data/TourStore.kt index 81cf0d4a0..c69561c4e 100644 --- a/driver-app/app/src/main/java/de/motis/prima/data/TourStore.kt +++ b/driver-app/app/src/main/java/de/motis/prima/data/TourStore.kt @@ -261,6 +261,10 @@ class TourStore @Inject constructor( return realm.query("tourId == $0", id).first().find() } + fun getEvent(id: Int): EventObject? { + return realm.query("id == $0", id).first().find() + } + fun getPickupRequestIDs(tourId: Int): Set { val res: MutableSet = mutableSetOf() val pickupEvents = getEventsForTour(tourId).filter { e -> e.isPickup } diff --git a/driver-app/app/src/main/java/de/motis/prima/services/Api.kt b/driver-app/app/src/main/java/de/motis/prima/services/Api.kt index 7724fc5a5..1a742deaf 100644 --- a/driver-app/app/src/main/java/de/motis/prima/services/Api.kt +++ b/driver-app/app/src/main/java/de/motis/prima/services/Api.kt @@ -63,6 +63,11 @@ interface ApiService { @Query("from") from: String, @Query("to") to: String ): Response + + @GET("api/driver/journey") + suspend fun getItinerary( + @Query("requestId") requestId: Int + ): Response } data class AvailabilityRequest( @@ -130,3 +135,80 @@ data class Tour( val licensePlate: String, val events: List ) + +data class Itinerary( + val duration: Long, + val startTime: String, + val endTime: String, + val transfers: Int, + val legs: List, + //val fareTransfers: List +) + +data class Leg( + val mode: String, + val from: Place, + val to: Place, + val duration: Long, + val startTime: String, + val endTime: String, + var scheduledStartTime: String?, + val scheduledEndTime: String?, + val realTime: Boolean, + val scheduled: Boolean, + //val distance: Double, + //val interlineWithPreviousLeg: Boolean, + val headsign: String?, + val tripTo: Place?, + //val routeId: String?, + //val directionId: String?, + val routeColor: String?, + val routeTextColor: String?, + //val routeType: Int?, + //val agencyName: String?, + //val agencyUrl: String?, + //val agencyId: String?, + val tripId: String?, + val routeShortName: String?, + val routeLongName: String?, + val tripShortName: String?, + val displayName: String?, + val cancelled: Boolean, + //val source: String?, + val intermediateStops: List, + //val legGeometry: Polyline?, + //val steps: List, + //val rental: Rental?, + //val fareTransferIndex: Int?, + //val effectiveFareLegIndex: Int?, + //val alerts: List, + //val loopedCalendarSince: String? +) + +data class Place( + val name: String?, + val stopId: String?, + val parentId: String?, + val importance: Double?, + val lat: Double, + val lon: Double, + val level: Int?, + val tz: String?, + val arrival: String?, + val departure: String?, + val scheduledArrival: String?, + val scheduledDeparture: String?, + val scheduledTrack: String?, + val track: String?, + val description: String?, + //val vertexType: VertexType?, + //val pickupType: PickupDropoffType?, + //val dropoffType: PickupDropoffType?, + val cancelled: Boolean, + //val alerts: List, + //val flex: String?, + //val flexId: String?, + //val flexStartPickupDropOffWindow: String?, + //val flexEndPickupDropOffWindow: String?, + //val modes: List +) diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/Availability.kt b/driver-app/app/src/main/java/de/motis/prima/ui/Availability.kt index ed7044a6a..6a3a539dc 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/Availability.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/Availability.kt @@ -267,7 +267,6 @@ class AvailabilityViewModel @Inject constructor( } val intervals = mergeModifications(fetchDate) - Log.d("merge", "merged: $intervals") //TODO: fix merging val from = mutableListOf() val to = mutableListOf() val add = mutableListOf() diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/DayTimeline.kt b/driver-app/app/src/main/java/de/motis/prima/ui/DayTimeline.kt index b16b7ea54..8b18e47c3 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/DayTimeline.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/DayTimeline.kt @@ -1,4 +1,3 @@ -import android.util.Log import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures @@ -41,7 +40,6 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import de.motis.prima.R import de.motis.prima.ui.AvailabilityViewModel -import java.time.LocalDate @Composable fun DayTimeline( diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/EventGroup.kt b/driver-app/app/src/main/java/de/motis/prima/ui/EventGroup.kt index e55af3248..23251ebb0 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/EventGroup.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/EventGroup.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -31,8 +33,10 @@ import androidx.compose.material3.ButtonColors import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -61,6 +65,7 @@ import de.motis.prima.data.EventObjectGroup import de.motis.prima.data.Ticket import de.motis.prima.data.ValidationStatus import de.motis.prima.ui.theme.LocalExtendedColors +import java.time.Instant import java.util.Date import javax.inject.Inject @@ -69,6 +74,8 @@ class EventGroupViewModel @Inject constructor( private val repository: DataRepository ) : ViewModel() { val storedTickets = repository.storedTickets + val ptLegs = repository.ptLegs + val updateError = repository.updateError fun getValidCount(eventGroupId: String): Int { var tickets = repository.getTicketsForEventGroup(eventGroupId) @@ -82,6 +89,18 @@ class EventGroupViewModel @Inject constructor( fun updateTicket(requestId: Int, ticketHash: String) { repository.updateTicketStore(Ticket(requestId, ticketHash, "", ValidationStatus.DONE)) } + + fun setItineraries(ids: Set) { + repository.setUpdateIds(ids) + } + + fun getItinerary(requestId: Int) { + repository.getItinerary(requestId) + } + + fun getEvent(id: Int): EventObject? { + return repository.getEvent(id) + } } data class Location( @@ -89,6 +108,7 @@ data class Location( val lng: Double, ) +@SuppressLint("QueryPermissionsNeeded") fun openGoogleMapsNavigation(to: Location, context: Context) { val gmmIntentUri = Uri.parse("google.navigation:q=${to.lat},${to.lng}") val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) @@ -143,6 +163,14 @@ fun EventGroup( // ignore } + LaunchedEffect(Unit) { + val requestIds = mutableSetOf() + for (event in eventGroup.events) { + requestIds.add(event.requestId) + } + viewModel.setItineraries(requestIds) + } + Column( modifier = Modifier .padding(10.dp), @@ -208,8 +236,9 @@ fun EventGroup( val validEvents = eventGroup.events .filter { e -> e.cancelled.not() } .sortedBy { it.scheduledTimeStart } + items(items = validEvents, itemContent = { event -> - ShowCustomerDetails(event, viewModel) + ShowEvent(event, viewModel, navController) }) } } @@ -302,27 +331,120 @@ fun EventGroup( } } +@Composable +fun ChangeIcon(isPickup: Boolean) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isPickup) Color(62, 130, 79) else Color(222, 132, 126)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPickup) Icons.AutoMirrored.Filled.ArrowForward else Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Localized description", + tint = LocalExtendedColors.current.cardColor + ) + } +} + @SuppressLint("DefaultLocale") @Composable -fun ShowCustomerDetails( +fun ShowEvent( event: EventObject, - viewModel: EventGroupViewModel + viewModel: EventGroupViewModel, + navController: NavController ) { val context = LocalContext.current val storedTickets = viewModel.storedTickets.collectAsState() val fareToPay: Double = (event.ticketPrice / 100).toDouble() + val updateError by viewModel.updateError.collectAsState() + + val ptLegs by viewModel.ptLegs.collectAsState() + val leg = ptLegs[event.requestId] + + var ptScheduledTime = "-:-" + var ptRealTime = "-:-" + var mode = "" + var ptRideCancelled = false + var ptStopCancelled = false + var toPT = false + var fromPT = false + + if (leg != null) { + val scheduledStartTime = leg.scheduledStartTime + val scheduledEndTime = leg.scheduledEndTime + val startTime = leg.scheduledStartTime // TODO + val endTime = leg.scheduledEndTime // TODO + + if (startTime != null && endTime != null && scheduledStartTime != null && scheduledEndTime != null) { + val scheduledStart = isoToLocalTime(scheduledStartTime) + val scheduledEnd = isoToLocalTime(scheduledEndTime) + val start = isoToLocalTime(startTime) + val end = isoToLocalTime(endTime) + + ptScheduledTime = if (event.isPickup) scheduledEnd else scheduledStart + ptRealTime = if (event.isPickup) end else start + } + + mode = leg.mode + ptRideCancelled = leg.cancelled + + // determine PT / ODM order + try { + val startInstant = Instant.parse(scheduledStartTime) + val endInstant = Instant.parse(scheduledEndTime) + toPT = event.isPickup.not() && event.scheduledTime < startInstant.toEpochMilli() + fromPT = event.isPickup && endInstant.toEpochMilli() < event.scheduledTime + + if (fromPT && leg.to.cancelled) { + ptStopCancelled = true + } + + if (toPT && leg.from.cancelled) { + ptStopCancelled = true + } + } catch (e: Exception) { + Log.e("error", e.message.toString()) + } + } + + val isPT = fromPT || toPT + val ptDelayed = ptRealTime != ptScheduledTime + var ptColor = if (ptDelayed) Color.Red else Color(62, 130, 79) + + if (ptStopCancelled) { + ptRealTime = "Halt fällt aus" + ptColor = Color.Red + } + if (ptRideCancelled) { + ptRealTime = "Fahrt fällt aus" + ptColor = Color.Red + } + + var icon = R.drawable.ic_public_transport + if (mode == "REGIONAL_RAIL") { + icon = R.drawable.ic_train + } + + LaunchedEffect(Unit) { + viewModel.getItinerary(event.requestId) + } + Card( modifier = Modifier .padding(top = 10.dp) .padding(horizontal = 10.dp), colors = CardColors(LocalExtendedColors.current.cardColor, Color.Black, Color.White, Color.White) ) { - Column { + Column( + modifier = Modifier.padding(start = 8.dp, end = 8.dp) + ) { + // event time Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, start = 12.dp, end = 12.dp), + .padding(top = 8.dp, start = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { val displayTime = if (event.scheduledTime.toInt() != 0) { @@ -341,133 +463,200 @@ fun ShowCustomerDetails( color = LocalExtendedColors.current.textColor ) } + Spacer(modifier = Modifier.height(6.dp)) + // change info Row( modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, start = 12.dp, end = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(if (event.isPickup) Color.Green else Color.Red), - contentAlignment = Alignment.Center - ) { + if (ptLegs[event.requestId] != null && updateError.not() && isPT) { Icon( - imageVector = if (event.isPickup) Icons.AutoMirrored.Filled.ArrowForward else Icons.AutoMirrored.Filled.ArrowBack, + painter = painterResource(id = icon), contentDescription = "Localized description", - tint = Color.White + Modifier.size(24.dp), + tint = if (ptStopCancelled || ptRideCancelled) Color.Red else LocalExtendedColors.current.textColor ) - } - - Box { - if (event.wheelchairs > 0) { - Icon( - painter = painterResource(id = R.drawable.ic_wheelchair), - contentDescription = "Localized description", - tint = LocalExtendedColors.current.textColor - ) - } - } + Spacer(modifier = Modifier.width(4.dp)) + ChangeIcon(event.isPickup) - Row { - Row( - verticalAlignment = Alignment.CenterVertically + // PT scheduled time + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = "Localized description", - tint = LocalExtendedColors.current.textColor - ) - Spacer(modifier = Modifier.width(4.dp)) Text( - text = "${event.passengers}", - fontSize = 24.sp, + text = ptScheduledTime, + fontSize = 16.sp, textAlign = TextAlign.Center, - color = LocalExtendedColors.current.textColor + color = if (ptStopCancelled || ptRideCancelled) Color.Red else LocalExtendedColors.current.textColor ) } - Spacer(modifier = Modifier.width(30.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically + // PT real time + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center ) { - Icon( - painter = painterResource(id = R.drawable.ic_luggage), - contentDescription = "Localized description", - tint = LocalExtendedColors.current.textColor - ) - Spacer(modifier = Modifier.width(4.dp)) Text( - text = "${event.luggage}", - fontSize = 24.sp, + text = ptRealTime, + fontSize = 16.sp, textAlign = TextAlign.Center, - color = LocalExtendedColors.current.textColor + color = ptColor ) } - Spacer(modifier = Modifier.width(30.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically + Spacer(modifier = Modifier.width(12.dp)) + + IconButton( + onClick = { + // open PT detail view + navController.navigate("itinerary/${event.requestId}/${event.id}") + }, + Modifier.size(width = 24.dp, height = 24.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_bike), + imageVector = Icons.Default.Info, contentDescription = "Localized description", - tint = LocalExtendedColors.current.textColor - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "${event.bikes}", - fontSize = 24.sp, - textAlign = TextAlign.Center, - color = LocalExtendedColors.current.textColor + tint = Color(94, 154, 191) ) } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 6.dp, end = 12.dp) + ) { + ChangeIcon(event.isPickup) + if (updateError) { + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = "offline", + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = Color.Red + ) + } + } + } + } + val hasSpecialInfo = event.wheelchairs > 0 || event.kidsZeroToTwo > 0 || event.luggage > 0 || event.bikes > 0 + + if (hasSpecialInfo) { + Spacer(modifier = Modifier.height(4.dp)) + // special info + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (event.wheelchairs > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp, end = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_wheelchair), + contentDescription = "Localized description", + tint = LocalExtendedColors.current.textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${event.wheelchairs}", + fontSize = 22.sp, + textAlign = TextAlign.Center, + color = LocalExtendedColors.current.textColor + ) + } + } + if (event.kidsZeroToTwo > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 12.dp, end = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baby_stroller), + contentDescription = "Localized description", + tint = LocalExtendedColors.current.textColor, + modifier = Modifier.size(21.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${event.kidsZeroToTwo}", + fontSize = 22.sp, + textAlign = TextAlign.Center, + color = LocalExtendedColors.current.textColor + ) + } + } + if (event.luggage > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_luggage), + contentDescription = "Localized description", + tint = LocalExtendedColors.current.textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${event.luggage}", + fontSize = 22.sp, + textAlign = TextAlign.Center, + color = LocalExtendedColors.current.textColor + ) + } + } + if (event.bikes > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 12.dp, end = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_bike), + contentDescription = "Localized description", + tint = LocalExtendedColors.current.textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${event.bikes}", + fontSize = 22.sp, + textAlign = TextAlign.Center, + color = LocalExtendedColors.current.textColor + ) + } + } } } + Spacer(modifier = Modifier.height(20.dp)) Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = event.customerName, - fontSize = 24.sp, + fontSize = 22.sp, textAlign = TextAlign.Center, color = LocalExtendedColors.current.textColor ) } - - if (!event.isPickup) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "${String.format("%.2f", fareToPay)} €", - fontSize = 24.sp, - textAlign = TextAlign.Center, - color = LocalExtendedColors.current.textColor - ) - } - } - - if (event.isPickup) { - Spacer(modifier = Modifier.height(20.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (event.isPickup) { if (event.customerPhone != null && event.customerPhone != "") { Button( onClick = { @@ -488,28 +677,58 @@ fun ShowCustomerDetails( .size(width = 24.dp, height = 24.dp) ) } + } else { + Box { /* place holder */ } + } - var ticketStatus: ValidationStatus = ValidationStatus.OPEN + var ticketStatus: ValidationStatus = ValidationStatus.OPEN - val ticketObject = storedTickets.value - .find { t -> t.ticketHash == event.ticketHash } + val ticketObject = storedTickets.value + .find { t -> t.ticketHash == event.ticketHash } - ticketObject?.let { ticket -> - ticketStatus = ValidationStatus.valueOf(ticket.validationStatus) - } + ticketObject?.let { ticket -> + ticketStatus = ValidationStatus.valueOf(ticket.validationStatus) + } + + if(event.ticketChecked) { + ticketStatus = ValidationStatus.DONE + viewModel.updateTicket(event.requestId, event.ticketHash) + } - if(event.ticketChecked) { - ticketStatus = ValidationStatus.DONE - viewModel.updateTicket(event.requestId, event.ticketHash) - } + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .height(height = 40.dp) + .padding(start = 6.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Localized description", + tint = LocalExtendedColors.current.textColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${event.passengers}", + fontSize = 22.sp, + textAlign = TextAlign.Center, + color = LocalExtendedColors.current.textColor + ) + } + Spacer(modifier = Modifier.width(width = 12.dp)) Text( text = "${String.format("%.2f", fareToPay)} €", - fontSize = 24.sp, + fontSize = 22.sp, textAlign = TextAlign.Center, color = LocalExtendedColors.current.textColor ) - + Spacer(modifier = Modifier.width(width = 12.dp)) Box( modifier = Modifier .clip(RoundedCornerShape(10.dp)) diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/Itinerary.kt b/driver-app/app/src/main/java/de/motis/prima/ui/Itinerary.kt new file mode 100644 index 000000000..0ba44a9fd --- /dev/null +++ b/driver-app/app/src/main/java/de/motis/prima/ui/Itinerary.kt @@ -0,0 +1,462 @@ +package de.motis.prima.ui + +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.Row +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.navigation.NavController +import dagger.hilt.android.lifecycle.HiltViewModel +import de.motis.prima.R +import de.motis.prima.data.DataRepository +import de.motis.prima.services.Place +import de.motis.prima.ui.theme.LocalExtendedColors +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +enum class TransportType { + TAXI, TRAIN, WALK +} + +data class ItineraryItem( + val arrivalTime: String, + val departureTime: String, + val from: String, + val to: String, + val intermediateStops: List = emptyList(), + val transportType: TransportType, + var isLast: Boolean = false, + val name: String, + val fromCancelled: Boolean = false, + val toCancelled: Boolean = false, + val cancelled: Boolean = false +) + +@HiltViewModel +class ItineraryViewModel @Inject constructor( + private val repository: DataRepository +) : ViewModel() { + val ptLegs = repository.ptLegs +} + +fun isoToLocalTime(isoString: String): String { + val instant = Instant.parse(isoString) + val localDateTime = instant.atZone(ZoneId.systemDefault()) + val formatter = DateTimeFormatter + .ofPattern("HH:mm", Locale.getDefault()) + val formatted = localDateTime.format(formatter) + + return formatted +} + +fun epochToLocalTime(epochMillis: Long): String { + val zoneId = ZoneId.systemDefault() + val time = Instant.ofEpochMilli(epochMillis).atZone(zoneId) + val formatter = DateTimeFormatter + .ofPattern("HH:mm", Locale.getDefault()) + val formatted = time.format(formatter) + + return formatted +} + +@Composable +fun ItineraryScreen( + navController: NavController, + requestId: Int, + eventId: Int, + viewModel: EventGroupViewModel = hiltViewModel(), +) { + val ptLegs by viewModel.ptLegs.collectAsState() + val itinerary = mutableListOf() + + val leg = ptLegs[requestId] + if (leg != null) { + itinerary.add( + ItineraryItem( + departureTime = isoToLocalTime(leg.from.scheduledDeparture ?: "-:-"), + arrivalTime = isoToLocalTime(leg.to.scheduledArrival ?: "-:-"), + from = leg.from.name.toString(), + to = leg.to.name.toString(), + intermediateStops = leg.intermediateStops, + transportType = TransportType.TRAIN, + name = leg.displayName.toString(), + fromCancelled = leg.from.cancelled, + toCancelled = leg.to.cancelled, + cancelled = leg.cancelled + ) + ) + } + + var taxiFrom = "" + var taxiTo = "" + var taxiDeparture = "" + var taxiArrival = "" + var isPickup = true + + val event = viewModel.getEvent(eventId) + if (event != null) { + isPickup = event.isPickup + val time = epochToLocalTime(event.scheduledTime) + if (event.isPickup) { + taxiFrom = event.address + taxiDeparture = time + } else { + taxiTo = event.address + taxiArrival = time + } + + itinerary.add( + ItineraryItem( + departureTime = taxiDeparture, + arrivalTime = taxiArrival, + from = taxiFrom, + to = taxiTo, + transportType = TransportType.TAXI, + name = "Taxi" + ) + ) + } + + itinerary.sortBy { e -> e.departureTime } + itinerary.last().isLast = true + + Scaffold( + topBar = { + TopBar( + "Itinerary", + false, + emptyList(), + navController + ) + } + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + LazyColumn( + modifier = Modifier + .padding(20.dp) + .background(Color.White) + ) { + items(itinerary) { item -> + ItineraryRow(item = item, isPickup) + } + } + } + } +} + +@Composable +fun ItineraryRow( + item: ItineraryItem, + isPickup: Boolean +) { + val isTaxi = item.transportType == TransportType.TAXI + val baseHeight = if (isTaxi) 80.dp else 160.dp + var height by remember { mutableStateOf(baseHeight) } + val interStopHeight = 30.dp + val distTimeName = 48.dp + + var intermediateStops = item.intermediateStops + intermediateStops = if (isPickup) { + intermediateStops.takeLast(3) + } else { + intermediateStops.take(3) + } + + val nInterStops = intermediateStops.size + var extended by remember { mutableStateOf(false) } + + val fromCancelled = item.fromCancelled + val toCancelled = item.toCancelled + val legCancelled = item.cancelled + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + TransportIcon(item.transportType, false, item.name) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(height) + ) { + Column( + modifier = Modifier + .width(18.dp) + ) { + if (item.isLast.not()) { + Row { + Spacer(modifier = Modifier.width(14.dp)) + Box( + modifier = Modifier + .width(2.dp) + .height(height) + .background(transportColor(item.transportType)) + ) + } + } else { + Row { + Spacer(modifier = Modifier.width(14.dp)) + Box( + modifier = Modifier + .width(2.dp) + .height(height - 30.dp) + .background(transportColor(item.transportType)) + ) + } + Row { + Spacer(modifier = Modifier.width(9.dp)) + Box { + TransportIcon(item.transportType, true, "") + } + } + } + } + if (legCancelled.not()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + if (isTaxi || isPickup.not()) { + Row { + val color = if (fromCancelled) Color.Red else LocalExtendedColors.current.textColor + val timeTxt = if (fromCancelled) "X" else item.departureTime + Text( + text = timeTxt, + style = MaterialTheme.typography.labelMedium, + color = color, + fontSize = 20.sp + ) + Spacer(modifier = Modifier.width(distTimeName)) + Text( + text = item.from, + style = MaterialTheme.typography.titleMedium, + color = color, + fontSize = 20.sp + ) + } + } + Row { + Column { + Box { + if (item.intermediateStops.isEmpty().not()) { + Spacer(modifier = Modifier.height(4.dp)) + Button( + colors = ButtonColors( + Color.White, + Color.White, + Color.White, + Color.White + ), + onClick = { + extended = extended.not() + height = if (extended) { + baseHeight + interStopHeight * nInterStops + } else { + baseHeight + } + }) { + Row { + Icon( + imageVector = if (extended) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "Localized description", + modifier = Modifier + .size(width = 20.dp, height = 20.dp) + .background(Color.White), + tint = LocalExtendedColors.current.textColor + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Zwischenhalte", + style = MaterialTheme.typography.bodySmall, + color = Color.Black, + fontSize = 20.sp + ) + } + } + } + } + if (extended) { + LazyColumn { + items(intermediateStops) { stop -> + val scheduledDeparture = isoToLocalTime(stop.scheduledDeparture ?: "-:-") + val name = stop.name ?: "-" + val textColor = if (stop.cancelled) Color.Red else LocalExtendedColors.current.textColor + val scheduledDepartureText = if (stop.cancelled) " X " else scheduledDeparture + Spacer(modifier = Modifier.height(12.dp)) + Row { + Text( + text = scheduledDepartureText, + style = MaterialTheme.typography.labelMedium, + color = textColor, + fontSize = 20.sp + ) + Spacer(modifier = Modifier.width(distTimeName)) + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + color = textColor, + fontSize = 20.sp + ) + } + } + } + } + } + } + if (isTaxi || isPickup) { + Row { + val color = if (toCancelled) Color.Red else LocalExtendedColors.current.textColor + val timeTxt = if (toCancelled) "X" else item.arrivalTime + Text( + text = timeTxt, + style = MaterialTheme.typography.labelMedium, + color = color, + fontSize = 20.sp + ) + Spacer(modifier = Modifier.width(distTimeName)) + Text( + text = item.to, + style = MaterialTheme.typography.titleMedium, + color = color, + fontSize = 20.sp + ) + } + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Localized description", + modifier = Modifier + .size(width = 40.dp, height = 40.dp) + .background(Color.White), + tint = Color.Red + ) + Spacer(modifier = Modifier.width(30.dp)) + Text( + text = "Fahrt fällt aus", + style = MaterialTheme.typography.titleMedium, + color = Color.Red, + fontSize = 20.sp + ) + } + } + } + } + } +} + +@Composable +fun TransportIcon(mode: TransportType, terminal: Boolean = false, name: String) { + val (icon, bgColor) = when (mode) { + TransportType.TAXI -> + R.drawable.ic_taxi to transportColor(TransportType.TAXI) + TransportType.TRAIN -> + R.drawable.ic_train to transportColor(TransportType.TRAIN) + TransportType.WALK -> + R.drawable.ic_walk to transportColor(TransportType.WALK) + } + + if (terminal.not()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(bgColor), + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier + .width(80.dp), // TODO: content width + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ){ + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text= name, + color = Color.White + ) + } + } + } else { + Box( + modifier = Modifier + .clip(CircleShape) + .size(12.dp) + .background(bgColor), + contentAlignment = Alignment.Center + ) {} + } +} + +fun transportColor(type: TransportType): Color = + when (type) { + TransportType.TAXI -> Color(0xFFFFC107) + TransportType.TRAIN -> Color(0xFFD32F2F) + TransportType.WALK -> Color(0xFFBDBDBD) + } diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/Nav.kt b/driver-app/app/src/main/java/de/motis/prima/ui/Nav.kt index 4cdab9eb1..47adca2df 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/Nav.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/Nav.kt @@ -118,6 +118,12 @@ fun Nav(intent: Intent?, viewModel: NavViewModel = hiltViewModel()) { composable(route = "availability") { Availability(navController) } + + composable(route = "itinerary/{requestId}/{eventId}") { + val requestId = it.arguments?.getString("requestId")?.toInt() + val eventId = it.arguments?.getString("eventId")?.toInt() + ItineraryScreen(navController, requestId!!, eventId!!) + } } } } diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/TicketScan.kt b/driver-app/app/src/main/java/de/motis/prima/ui/TicketScan.kt index 342d2a790..54119d547 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/TicketScan.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/TicketScan.kt @@ -77,7 +77,7 @@ fun TicketScan( Scaffold( topBar = { TopBar( - " Scan Ticket Code", + "Scan Ticket Code", false, emptyList(), navController diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/TopBar.kt b/driver-app/app/src/main/java/de/motis/prima/ui/TopBar.kt index c94faa262..6cb2094df 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/TopBar.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/TopBar.kt @@ -74,6 +74,7 @@ fun TopBar( navController.popBackStack() } else { navController.navigate("tours") + viewModel.stopPolling() } }) { Icon( @@ -94,20 +95,21 @@ fun TopBar( DropdownMenuItem( onClick = { navController.navigate("tours") - + viewModel.stopPolling() }, text = { Text(text = stringResource(id = R.string.tours_header)) } ) DropdownMenuItem( onClick = { navController.navigate("availability") - + viewModel.stopPolling() }, - text = { Text(text = "Verfügbarkeit") } + text = { Text(text = stringResource(id = R.string.availability)) } ) for (item in navItems) { DropdownMenuItem( onClick = { + viewModel.stopPolling() dropdownExpanded = false item.action() @@ -121,10 +123,11 @@ fun TopBar( dropdownExpanded = false }, - text = { Text(text = "Toggle Theme") } + text = { Text(text = stringResource(id = R.string.toggle_theme)) } ) DropdownMenuItem( onClick = { + viewModel.stopPolling() viewModel.logout() dropdownExpanded = false diff --git a/driver-app/app/src/main/java/de/motis/prima/ui/TourPreview.kt b/driver-app/app/src/main/java/de/motis/prima/ui/TourPreview.kt index 45eee7933..89745fe8d 100644 --- a/driver-app/app/src/main/java/de/motis/prima/ui/TourPreview.kt +++ b/driver-app/app/src/main/java/de/motis/prima/ui/TourPreview.kt @@ -110,6 +110,10 @@ class TourViewModel @Inject constructor( fun getTourSpecialInfo(tourId: Int): TourSpecialInfo { return repository.getTourSpecialInfo(tourId) } + + fun startPolling() { + repository.initRealTimePolling() + } } @Composable @@ -335,6 +339,7 @@ fun WayPointsView(viewModel: TourViewModel, tourId: Int, navController: NavContr ) { Button( onClick = { + viewModel.startPolling() navController.navigate("leg/$tourId/0") } ) { diff --git a/driver-app/app/src/main/java/de/motis/prima/viewmodel/FareViewModel.kt b/driver-app/app/src/main/java/de/motis/prima/viewmodel/FareViewModel.kt index 9efc5c846..709468c82 100644 --- a/driver-app/app/src/main/java/de/motis/prima/viewmodel/FareViewModel.kt +++ b/driver-app/app/src/main/java/de/motis/prima/viewmodel/FareViewModel.kt @@ -29,6 +29,7 @@ class FareViewModel @Inject constructor( init { repository.fetchTours() + repository.stopPolling() } fun reportFare(tourId: Int, fare: String) { diff --git a/driver-app/app/src/main/java/de/motis/prima/viewmodel/TopBarViewModel.kt b/driver-app/app/src/main/java/de/motis/prima/viewmodel/TopBarViewModel.kt index 409b5a334..aa0d76f72 100644 --- a/driver-app/app/src/main/java/de/motis/prima/viewmodel/TopBarViewModel.kt +++ b/driver-app/app/src/main/java/de/motis/prima/viewmodel/TopBarViewModel.kt @@ -35,4 +35,8 @@ class TopBarViewModel @Inject constructor( fun toggleTheme() { repository.toggleTheme() } + + fun stopPolling() { + repository.stopPolling() + } } diff --git a/driver-app/app/src/main/res/drawable/ic_baby_stroller.xml b/driver-app/app/src/main/res/drawable/ic_baby_stroller.xml new file mode 100644 index 000000000..42f6ff53d --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_baby_stroller.xml @@ -0,0 +1,12 @@ + + + + diff --git a/driver-app/app/src/main/res/drawable/ic_bus.xml b/driver-app/app/src/main/res/drawable/ic_bus.xml new file mode 100644 index 000000000..333487d8c --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_bus.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_public_transport.xml b/driver-app/app/src/main/res/drawable/ic_public_transport.xml new file mode 100644 index 000000000..e9664d68c --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_public_transport.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_sbahn.xml b/driver-app/app/src/main/res/drawable/ic_sbahn.xml new file mode 100644 index 000000000..e3b5308c5 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_sbahn.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_taxi.xml b/driver-app/app/src/main/res/drawable/ic_taxi.xml new file mode 100644 index 000000000..5586bc630 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_taxi.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_train.xml b/driver-app/app/src/main/res/drawable/ic_train.xml new file mode 100644 index 000000000..4eceee243 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_train.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_tram.xml b/driver-app/app/src/main/res/drawable/ic_tram.xml new file mode 100644 index 000000000..3af3b6dd9 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_tram.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_ubahn.xml b/driver-app/app/src/main/res/drawable/ic_ubahn.xml new file mode 100644 index 000000000..229e0aed3 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_ubahn.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/drawable/ic_walk.xml b/driver-app/app/src/main/res/drawable/ic_walk.xml new file mode 100644 index 000000000..0f7749640 --- /dev/null +++ b/driver-app/app/src/main/res/drawable/ic_walk.xml @@ -0,0 +1,9 @@ + + + diff --git a/driver-app/app/src/main/res/values/strings.xml b/driver-app/app/src/main/res/values/strings.xml index 282888d68..37ba5d768 100644 --- a/driver-app/app/src/main/res/values/strings.xml +++ b/driver-app/app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ Logout Bitte überprüfen Sie Ihre Internetverbindung. Aktualisieren + Toggle Theme Tour Übersicht Ihr Nutzerkonto ist nicht freigeschaltet. Ein unbekannter Fehler ist aufgetreten.