diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt index 77bfcc8..7d79149 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt @@ -666,6 +666,54 @@ private fun StatusIcon( } } +/** + * Formats duration since a given ISO timestamp as "XXm" or "XXh YYm" + */ +private fun formatDurationSince(isoTimestamp: String?): String? { + if (isoTimestamp == null) return null + return try { + val instant = java.time.OffsetDateTime.parse(isoTimestamp).toInstant() + val now = java.time.Instant.now() + val duration = java.time.Duration.between(instant, now) + val totalMinutes = duration.toMinutes() + if (totalMinutes < 0) return null + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + if (hours > 0) { + "${hours}h ${minutes}m" + } else { + "${minutes}m" + } + } catch (e: Exception) { + null + } +} + +/** + * Formats an ISO timestamp to a human-readable format: + * - Today: "HH:mm" + * - Yesterday: "yesterday HH:mm" + * - Older: "DD/MM HH:mm" + */ +private fun formatTimeFromTimestamp(isoTimestamp: String?, yesterdayStr: String): String? { + if (isoTimestamp == null) return null + return try { + val dateTime = java.time.OffsetDateTime.parse(isoTimestamp) + val localDateTime = dateTime.toLocalDateTime() + val today = java.time.LocalDate.now() + val yesterday = today.minusDays(1) + val timeStr = String.format("%02d:%02d", localDateTime.hour, localDateTime.minute) + + when (localDateTime.toLocalDate()) { + today -> timeStr + yesterday -> "$yesterdayStr $timeStr" + else -> String.format("%02d/%02d %s", localDateTime.dayOfMonth, localDateTime.monthValue, timeStr) + } + } catch (e: Exception) { + null + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StatusIndicatorsRow( @@ -677,137 +725,163 @@ private fun StatusIndicatorsRow( val isSentryModeActive = status.sentryMode == true val isClimateOn = status.isClimateOn == true val isOnline = status.state?.lowercase() == "online" + val isAsleep = status.state?.lowercase() in listOf("asleep", "suspended") val isLocked = status.locked == true - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Left side: Status icons + Column(modifier = modifier) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Power/state icon - green when online, grey otherwise - StatusIcon( - icon = Icons.Filled.PowerSettingsNew, - tooltipText = status.state?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.unknown), - tint = if (isOnline) StatusSuccess else palette.onSurfaceVariant - ) + // Left side: Status icons + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // State icon - bedtime when asleep, power icon otherwise + val yesterdayStr = stringResource(R.string.yesterday) + val stateTooltip = if (isAsleep) { + val sleepTime = formatTimeFromTimestamp(status.stateSince, yesterdayStr) + if (sleepTime != null) { + stringResource(R.string.asleep_since, sleepTime) + } else { + status.state?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.unknown) + } + } else { + status.state?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.unknown) + } + StatusIcon( + icon = if (isAsleep) Icons.Filled.Bedtime else Icons.Filled.PowerSettingsNew, + tooltipText = stateTooltip, + tint = if (isOnline) StatusSuccess else palette.onSurfaceVariant + ) - // Lock icon - grey when locked, light red when unlocked - StatusIcon( - icon = if (isLocked) Icons.Filled.Lock else Icons.Filled.LockOpen, - tooltipText = stringResource(if (isLocked) R.string.locked else R.string.unlocked), - tint = if (isLocked) palette.onSurfaceVariant else StatusError.copy(alpha = 0.7f) - ) + // Lock icon - grey when locked, light red when unlocked + StatusIcon( + icon = if (isLocked) Icons.Filled.Lock else Icons.Filled.LockOpen, + tooltipText = stringResource(if (isLocked) R.string.locked else R.string.unlocked), + tint = if (isLocked) palette.onSurfaceVariant else StatusError.copy(alpha = 0.7f) + ) - // Sentry mode red dot (if active) - if (isSentryModeActive) { - val sentryTooltipState = rememberTooltipState(isPersistent = true) - val scope = rememberCoroutineScope() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(R.string.sentry_mode_active)) - } - }, - state = sentryTooltipState - ) { - Box( - modifier = Modifier - .size(12.dp) - .background(StatusError, RoundedCornerShape(6.dp)) - .clickable { scope.launch { sentryTooltipState.show() } } + // Sentry mode red dot (if active) + if (isSentryModeActive) { + val sentryTooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(stringResource(R.string.sentry_mode_active)) + } + }, + state = sentryTooltipState + ) { + Box( + modifier = Modifier + .size(12.dp) + .background(StatusError, RoundedCornerShape(6.dp)) + .clickable { scope.launch { sentryTooltipState.show() } } + ) + } + } + + // Plug icon (grey, if plugged in) + if (status.pluggedIn == true) { + StatusIcon( + icon = Icons.Filled.Power, + tooltipText = stringResource(R.string.plugged_in), + tint = palette.onSurfaceVariant ) } } - // Plug icon (grey, if plugged in) - if (status.pluggedIn == true) { - StatusIcon( - icon = Icons.Filled.Power, - tooltipText = stringResource(R.string.plugged_in), - tint = palette.onSurfaceVariant - ) + // Right side: Temperature indicators with labels + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val climateTooltip = stringResource(if (isClimateOn) R.string.climate_active else R.string.climate_inactive) + val scope = rememberCoroutineScope() + + // Outside temp: "Ext:" + val extTooltipState = rememberTooltipState(isPersistent = true) + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(climateTooltip) } }, + state = extTooltipState + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { scope.launch { extTooltipState.show() } } + ) { + Text( + text = stringResource(R.string.temp_ext_label), + style = MaterialTheme.typography.labelMedium, + color = palette.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.Thermostat, + contentDescription = stringResource(R.string.outside_temp), + modifier = Modifier.size(14.dp), + tint = palette.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = status.outsideTemp?.let { UnitFormatter.formatTemperature(it, units) } ?: "--", + style = MaterialTheme.typography.labelMedium, + color = palette.onSurfaceVariant + ) + } + } + + // Inside temp: "Int:" (bold and green if climate is on) + val intTooltipState = rememberTooltipState(isPersistent = true) + val intColor = if (isClimateOn) StatusSuccess else palette.onSurfaceVariant + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(climateTooltip) } }, + state = intTooltipState + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { scope.launch { intTooltipState.show() } } + ) { + Text( + text = stringResource(R.string.temp_int_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isClimateOn) FontWeight.Bold else FontWeight.Normal, + color = intColor + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.Thermostat, + contentDescription = stringResource(R.string.inside_temp), + modifier = Modifier.size(14.dp), + tint = intColor + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = status.insideTemp?.let { UnitFormatter.formatTemperature(it, units) } ?: "--", + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isClimateOn) FontWeight.Bold else FontWeight.Normal, + color = intColor + ) + } + } } } - // Right side: Temperature indicators with labels - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val climateTooltip = stringResource(if (isClimateOn) R.string.climate_active else R.string.climate_inactive) - val scope = rememberCoroutineScope() - - // Outside temp: "Ext:" - val extTooltipState = rememberTooltipState(isPersistent = true) - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { PlainTooltip { Text(climateTooltip) } }, - state = extTooltipState - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { scope.launch { extTooltipState.show() } } - ) { - Text( - text = stringResource(R.string.temp_ext_label), - style = MaterialTheme.typography.labelMedium, - color = palette.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.Thermostat, - contentDescription = stringResource(R.string.outside_temp), - modifier = Modifier.size(14.dp), - tint = palette.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = status.outsideTemp?.let { UnitFormatter.formatTemperature(it, units) } ?: "--", - style = MaterialTheme.typography.labelMedium, - color = palette.onSurfaceVariant - ) - } - } - - // Inside temp: "Int:" (bold and green if climate is on) - val intTooltipState = rememberTooltipState(isPersistent = true) - val intColor = if (isClimateOn) StatusSuccess else palette.onSurfaceVariant - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { PlainTooltip { Text(climateTooltip) } }, - state = intTooltipState - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { scope.launch { intTooltipState.show() } } - ) { - Text( - text = stringResource(R.string.temp_int_label), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isClimateOn) FontWeight.Bold else FontWeight.Normal, - color = intColor - ) - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.Thermostat, - contentDescription = stringResource(R.string.inside_temp), - modifier = Modifier.size(14.dp), - tint = intColor - ) - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = status.insideTemp?.let { UnitFormatter.formatTemperature(it, units) } ?: "--", - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isClimateOn) FontWeight.Bold else FontWeight.Normal, - color = intColor - ) - } + // Show sleep duration when asleep + if (isAsleep) { + val sleepDuration = formatDurationSince(status.stateSince) + if (sleepDuration != null) { + Text( + text = sleepDuration, + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) } } } diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ef82936..375f53b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -72,6 +72,12 @@ Clima inactiu + + Adormit des de %s + + + ahir + Imatge del cotxe - toca per estadístiques diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09a5e20..7853e14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,12 @@ Climate inactive + + Asleep since %s + + + yesterday + Car image - tap for stats