From 6bf225e5a0649492a9b70c0310d7231a3be596ff Mon Sep 17 00:00:00 2001 From: Jonas Heubuch Date: Mon, 5 Aug 2024 17:04:25 +0200 Subject: [PATCH] :sparkles: Add view for current check-in (#401) --- .../de/hbch/traewelling/api/ApiService.kt | 3 + .../api/interceptors/LogInterceptor.kt | 2 +- .../shared/LoggedInUserViewModel.kt | 12 ++ .../traewelling/ui/dashboard/Dashboard.kt | 6 + .../include/cardSearchStation/CardSearch.kt | 2 +- .../ui/include/status/ActiveStatusBar.kt | 120 ++++++++++++++++++ .../ui/include/status/CheckInCard.kt | 34 ++++- .../hbch/traewelling/ui/main/MainActivity.kt | 112 +++++++++------- app/src/main/res/values-de-rDE/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 242 insertions(+), 53 deletions(-) create mode 100644 app/src/main/kotlin/de/hbch/traewelling/ui/include/status/ActiveStatusBar.kt diff --git a/app/src/main/kotlin/de/hbch/traewelling/api/ApiService.kt b/app/src/main/kotlin/de/hbch/traewelling/api/ApiService.kt index 425de60c..1c9c94a3 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/api/ApiService.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/api/ApiService.kt @@ -99,6 +99,9 @@ interface StatisticsService { } interface CheckInService { + @GET("user/statuses/active") + suspend fun getOwnActiveStatus(): Response> + @GET("dashboard") fun getPersonalDashboard( @Query("page") page: Int diff --git a/app/src/main/kotlin/de/hbch/traewelling/api/interceptors/LogInterceptor.kt b/app/src/main/kotlin/de/hbch/traewelling/api/interceptors/LogInterceptor.kt index 02cd3600..b4f530e5 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/api/interceptors/LogInterceptor.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/api/interceptors/LogInterceptor.kt @@ -11,7 +11,7 @@ class LogInterceptor : Interceptor { val copy = request.newBuilder().build() val response = chain.proceed(request) - val ignoredCodes = listOf(401, 409) + val ignoredCodes = listOf(401, 409, 404) val path = request.url.encodedPath if (!response.isSuccessful && !ignoredCodes.contains(response.code)) { diff --git a/app/src/main/kotlin/de/hbch/traewelling/shared/LoggedInUserViewModel.kt b/app/src/main/kotlin/de/hbch/traewelling/shared/LoggedInUserViewModel.kt index b76b9836..ce58362f 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/shared/LoggedInUserViewModel.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/shared/LoggedInUserViewModel.kt @@ -13,6 +13,7 @@ import de.hbch.traewelling.api.TraewellingApi import de.hbch.traewelling.api.WebhookRelayApi import de.hbch.traewelling.api.models.Data import de.hbch.traewelling.api.models.station.Station +import de.hbch.traewelling.api.models.status.Status import de.hbch.traewelling.api.models.status.StatusVisibility import de.hbch.traewelling.api.models.user.User import de.hbch.traewelling.ui.login.LoginActivity @@ -41,6 +42,8 @@ class LoggedInUserViewModel : ViewModel() { val defaultStatusVisibility: StatusVisibility get() = _user.value?.defaultStatusVisibility ?: StatusVisibility.PUBLIC + val currentStatus = MutableLiveData(null) + fun setHomelandStation(station: Station) { _user.value?.home = station } @@ -174,4 +177,13 @@ class LoggedInUserViewModel : ViewModel() { } }) } + + suspend fun updateCurrentStatus() { + val response = TraewellingApi.checkInService.getOwnActiveStatus() + if (response.code() != 404) { + currentStatus.postValue(response.body()?.data) + } else { + currentStatus.postValue(null) + } + } } diff --git a/app/src/main/kotlin/de/hbch/traewelling/ui/dashboard/Dashboard.kt b/app/src/main/kotlin/de/hbch/traewelling/ui/dashboard/Dashboard.kt index 8c9fc9f5..173cd447 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/ui/dashboard/Dashboard.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/ui/dashboard/Dashboard.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,6 +27,7 @@ import de.hbch.traewelling.ui.include.cardSearchStation.CardSearch import de.hbch.traewelling.ui.include.status.CheckInCardViewModel import de.hbch.traewelling.util.OnBottomReached import de.hbch.traewelling.util.checkInList +import kotlinx.coroutines.launch import java.time.ZonedDateTime @OptIn(ExperimentalMaterialApi::class) @@ -45,6 +47,7 @@ fun Dashboard( val checkInCardViewModel : CheckInCardViewModel = viewModel() val refreshing by dashboardViewModel.isRefreshing.observeAsState(false) val checkIns = remember { dashboardViewModel.checkIns } + val coroutineScope = rememberCoroutineScope() var currentPage by remember { mutableIntStateOf(1) } val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, @@ -61,6 +64,9 @@ fun Dashboard( } else { loggedInUserViewModel.getLoggedInUser() loggedInUserViewModel.getLastVisitedStations { } + coroutineScope.launch { + loggedInUserViewModel.updateCurrentStatus() + } } } diff --git a/app/src/main/kotlin/de/hbch/traewelling/ui/include/cardSearchStation/CardSearch.kt b/app/src/main/kotlin/de/hbch/traewelling/ui/include/cardSearchStation/CardSearch.kt index 5b60feb9..21360a10 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/ui/include/cardSearchStation/CardSearch.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/ui/include/cardSearchStation/CardSearch.kt @@ -43,7 +43,7 @@ fun CardSearch( style = AppTypography.headlineLarge, modifier = Modifier.padding(8.dp), fontFamily = Twindexx, - color = LocalColorScheme.current.onPrimaryContainer + color = LocalColorScheme.current.primary ) Search( homelandStation = homelandStation, diff --git a/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/ActiveStatusBar.kt b/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/ActiveStatusBar.kt new file mode 100644 index 00000000..75888001 --- /dev/null +++ b/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/ActiveStatusBar.kt @@ -0,0 +1,120 @@ +package de.hbch.traewelling.ui.include.status + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +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.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.hbch.traewelling.R +import de.hbch.traewelling.api.models.status.Status +import de.hbch.traewelling.ui.composables.LineIcon +import de.hbch.traewelling.ui.user.getDurationString +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.ZonedDateTime +import kotlin.math.absoluteValue + +@Composable +fun ActiveStatusBar( + status: Status?, + modifier: Modifier = Modifier +) { + if (status != null) { + var progress by remember { mutableFloatStateOf(0f) } + val progressAnimation by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + label = "AnimateActiveStatusBarProgress", + ) + var duration by remember { mutableIntStateOf(0) } + + LaunchedEffect(true) { + while (true) { + progress = calculateProgress( + from = status.journey.departureManual ?: status.journey.origin.departureReal ?: status.journey.origin.departurePlanned, + to = status.journey.destination.arrivalReal ?: status.journey.destination.arrivalPlanned + ) + duration = Duration.between( + status.journey.destination.arrivalReal ?: status.journey.destination.arrivalPlanned, + ZonedDateTime.now() + ).toMinutes().absoluteValue.toInt() + delay(5000) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + LineIcon( + lineName = status.journey.line, + journeyNumber = null, + lineId = status.journey.lineId, + operatorCode = status.journey.operator?.id + ) + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = null + ) + Text( + text = status.journey.destination.name + ) + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (duration > 0) { + Text( + text = stringResource(id = R.string.time_left, getDurationString(duration)) + ) + } + Box( + modifier = Modifier + .background(Color.Blue) + .padding(4.dp) + ) { + Text( + text = stringResource(id = R.string.platform, + status.journey.destination.arrivalPlatformReal + ?: status.journey.destination.arrivalPlatformPlanned ?: ""), + color = Color.White + ) + } + } + } + LinearProgressIndicator( + progress = { progressAnimation }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/CheckInCard.kt b/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/CheckInCard.kt index 1c0a737c..b0b9aaa1 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/CheckInCard.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/ui/include/status/CheckInCard.kt @@ -4,6 +4,9 @@ import android.icu.text.MeasureFormat import android.icu.util.Measure import android.icu.util.MeasureUnit import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -28,8 +31,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -73,6 +78,7 @@ import de.hbch.traewelling.ui.user.getDurationString import de.hbch.traewelling.util.getLocalDateTimeString import de.hbch.traewelling.util.getLocalTimeString import de.hbch.traewelling.util.shareStatus +import kotlinx.coroutines.delay import java.time.Duration import java.time.ZonedDateTime import java.util.Locale @@ -97,6 +103,25 @@ fun CheckInCard( val statusClickedAction: () -> Unit = { statusSelected(status.id) } + + var progress by remember { mutableFloatStateOf(0f) } + val progressAnimation by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + label = "AnimateCheckInCardProgress", + ) + LaunchedEffect(true) { + while (true) { + progress = calculateProgress( + from = status.journey.departureManual ?: status.journey.origin.departureReal + ?: status.journey.origin.departurePlanned, + to = status.journey.arrivalManual ?: status.journey.destination.arrivalReal + ?: status.journey.destination.arrivalPlanned + ) + delay(5000) + } + } + ElevatedCard( modifier = modifier.fillMaxWidth(), onClick = statusClickedAction @@ -209,13 +234,9 @@ fun CheckInCard( textClicked = statusClickedAction ) } - val progress = calculateProgress( - from = status.journey.departureManual ?: status.journey.origin.departureReal ?: status.journey.origin.departurePlanned, - to = status.journey.arrivalManual ?: status.journey.destination.arrivalReal ?: status.journey.destination.arrivalPlanned - ) LinearProgressIndicator( progress = { - if (progress.isNaN()) 1f else progress + if (progressAnimation.isNaN()) 1f else progressAnimation }, modifier = Modifier .fillMaxWidth() @@ -245,8 +266,7 @@ fun CheckInCard( } } -@Composable -private fun calculateProgress( +fun calculateProgress( from: ZonedDateTime, to: ZonedDateTime ): Float { diff --git a/app/src/main/kotlin/de/hbch/traewelling/ui/main/MainActivity.kt b/app/src/main/kotlin/de/hbch/traewelling/ui/main/MainActivity.kt index 08e44c6e..5e314857 100644 --- a/app/src/main/kotlin/de/hbch/traewelling/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/de/hbch/traewelling/ui/main/MainActivity.kt @@ -9,8 +9,11 @@ import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -19,6 +22,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -79,6 +83,7 @@ import de.hbch.traewelling.shared.SettingsViewModel import de.hbch.traewelling.shared.SharedValues import de.hbch.traewelling.theme.LocalColorScheme import de.hbch.traewelling.theme.MainTheme +import de.hbch.traewelling.ui.include.status.ActiveStatusBar import de.hbch.traewelling.ui.notifications.NotificationsViewModel import de.hbch.traewelling.util.popBackStackAndNavigate import de.hbch.traewelling.util.publishStationShortcuts @@ -197,6 +202,7 @@ fun TraewelldroidApp( val loggedInUser by loggedInUserViewModel.loggedInUser.observeAsState() val lastVisitedStations by loggedInUserViewModel.lastVisitedStations.observeAsState() val homelandStation by loggedInUserViewModel.home.observeAsState() + val currentStatus by loggedInUserViewModel.currentStatus.observeAsState() LaunchedEffect(lastVisitedStations, homelandStation) { context.publishStationShortcuts(homelandStation, lastVisitedStations) @@ -329,55 +335,73 @@ fun TraewelldroidApp( enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }) ) { - NavigationBar { - BOTTOM_NAVIGATION.forEach { destination -> - NavigationBarItem( - icon = { - BadgedBox( - badge = { - if (destination == Notifications && unreadNotificationCount > 0) { - Badge { - Text( - text = unreadNotificationCount.toString() - ) + Column { + AnimatedVisibility(visible = currentStatus != null) { + BottomAppBar( + windowInsets = WindowInsets(bottom = 0.dp, left = 0.dp, right = 0.dp) + ) { + ActiveStatusBar( + status = currentStatus, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate( + "status-details/${currentStatus?.id}" + ) + } + ) + } + } + NavigationBar { + BOTTOM_NAVIGATION.forEach { destination -> + NavigationBarItem( + icon = { + BadgedBox( + badge = { + if (destination == Notifications && unreadNotificationCount > 0) { + Badge { + Text( + text = unreadNotificationCount.toString() + ) + } } } - } - ) { - val user = loggedInUser - if ( - destination == PersonalProfile && - user != null ) { - AsyncImage( - model = user.avatarUrl, - contentDescription = user.name, - modifier = Modifier - .size(24.dp) - .clip(CircleShape), - placeholder = painterResource(id = destination.icon) - ) - } else { - Icon( - painter = painterResource(id = destination.icon), - contentDescription = null - ) + val user = loggedInUser + if ( + destination == PersonalProfile && + user != null + ) { + AsyncImage( + model = user.avatarUrl, + contentDescription = user.name, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + placeholder = painterResource(id = destination.icon) + ) + } else { + Icon( + painter = painterResource(id = destination.icon), + contentDescription = null + ) + } } + }, + label = { + Text( + text = stringResource(id = destination.label), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + selected = currentScreen == destination, + onClick = { + navController.popBackStackAndNavigate(destination.route) + appBarState.contentOffset = 0f } - }, - label = { - Text( - text = stringResource(id = destination.label), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - selected = currentScreen == destination, - onClick = { - navController.popBackStackAndNavigate(destination.route) - appBarState.contentOffset = 0f - } - ) + ) + } } } } diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 05374dbc..65c9245b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -251,4 +251,6 @@ Danke für deine Meldung! Das Träwelling-Team wird diese so schnell wie möglich bearbeiten. Die Meldung konnte nicht erstellt werden. Wenn dein Anliegen dringend ist, kontaktiere bitte den Träwelling-Support über die Webseite. Bitte nutze die Träwelling-Webseite, um diese Benachrichtigung zu sehen. + noch %1$s + Gl. %1$s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 600f1381..4a20c5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,4 +278,6 @@ Thank you for your report! The Träwelling team will evaluate it as soon as possible. The report could not be created. If your issue is urgent, please contact the Träwelling support on the website. Please use the Träwelling website to view this notification. + %1$s left + pl. %1$s \ No newline at end of file