diff --git a/app/src/main/java/com/bnyro/clock/obj/TimeObject.kt b/app/src/main/java/com/bnyro/clock/obj/TimeObject.kt index 3ecbf8d1..c8e91141 100644 --- a/app/src/main/java/com/bnyro/clock/obj/TimeObject.kt +++ b/app/src/main/java/com/bnyro/clock/obj/TimeObject.kt @@ -12,4 +12,29 @@ data class TimeObject( return "${hours.addZero()}:${minutes.addZero()}:${seconds.addZero()}.${(milliseconds / 10).addZero()}" .replace("^(00:)*".toRegex(), "") } + + fun toFullString(): String { + return String.format("%02d:%02d.%02d", minutes + hours * 60, seconds, milliseconds / 10) + } + + operator fun minus(value: TimeObject): TimeObject { + var hours = + (this.hours - value.hours) + var minutes = + (this.minutes - value.minutes).also { if (it < 0) hours -= 1 } + .let { if (it < 0) 60 + it else it } + var seconds = + (this.seconds - value.seconds).also { if (it < 0) minutes -= 1 } + .let { if (it < 0) 60 + it else it } + val milliseconds = + (this.milliseconds - value.milliseconds).also { if (it < 0) seconds -= 1 } + .let { if (it < 0) 1000 + it else it } + + return TimeObject( + hours, + minutes, + seconds, + milliseconds + ) + } } diff --git a/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt b/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt index 519eb58b..50994610 100644 --- a/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt +++ b/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt @@ -8,11 +8,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel +import com.bnyro.clock.obj.TimeObject import com.bnyro.clock.obj.WatchState import com.bnyro.clock.services.StopwatchService +import com.bnyro.clock.util.TimeHelper class StopwatchModel : ViewModel() { - val rememberedTimeStamps = mutableStateListOf() + /** + * List of laps with overall time <> lap time + */ + val rememberedTimeStamps = mutableStateListOf>() var currentPosition by mutableStateOf(0) var state: WatchState by mutableStateOf(WatchState.IDLE) @@ -28,6 +33,17 @@ class StopwatchModel : ViewModel() { context.sendBroadcast(startIntent) } + fun onLapClicked() { + val overallTime = TimeHelper.millisToTime(currentPosition.toLong()) + if (rememberedTimeStamps.isNotEmpty()) { + val lastLap = rememberedTimeStamps.last() + rememberedTimeStamps.add(Pair(overallTime, overallTime - lastLap.first)) + } else { + rememberedTimeStamps.add(Pair(overallTime, overallTime)) + } + + } + fun pauseResumeStopwatch(context: Context) { when (state) { WatchState.IDLE -> startStopwatch(context) diff --git a/app/src/main/java/com/bnyro/clock/ui/nav/NavContainer.kt b/app/src/main/java/com/bnyro/clock/ui/nav/NavContainer.kt index 01c660de..fd09bae4 100644 --- a/app/src/main/java/com/bnyro/clock/ui/nav/NavContainer.kt +++ b/app/src/main/java/com/bnyro/clock/ui/nav/NavContainer.kt @@ -1,12 +1,15 @@ package com.bnyro.clock.ui.nav -import android.util.Log +import android.content.res.Configuration import androidx.activity.addCallback +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -27,7 +31,6 @@ import androidx.navigation.compose.rememberNavController import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.ui.model.ClockModel import com.bnyro.clock.ui.model.SettingsModel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch val bottomNavItems = listOf( @@ -82,36 +85,59 @@ fun NavContainer( } } + val orientation = LocalConfiguration.current.orientation Scaffold( bottomBar = { - NavigationBar( - tonalElevation = 5.dp - ) { - bottomNavItems.forEach { - NavigationBarItem( - label = { - Text(stringResource(it.stringRes)) - }, - icon = { - Icon(it.icon, null) - }, - selected = it == selectedRoute, - onClick = { - selectedRoute = it - navController.navigate(it.route) - } - ) + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + NavigationBar( + tonalElevation = 5.dp + ) { + bottomNavItems.forEach { + NavigationBarItem( + label = { + Text(stringResource(it.stringRes)) + }, + icon = { + Icon(it.icon, null) + }, + selected = it == selectedRoute, + onClick = { + selectedRoute = it + navController.navigate(it.route) + } + ) + } } } } ) { pV -> - AppNavHost( - navController, - settingsModel, - clockModel, - modifier = Modifier + Row( + Modifier .fillMaxSize() .padding(pV) - ) + ) { + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + NavigationRail { + bottomNavItems.forEach { + NavigationRailItem(selected = it == selectedRoute, + onClick = { + selectedRoute = it + navController.navigate(it.route) + }, + icon = { Icon(it.icon, null) }, + label = { + Text(stringResource(it.stringRes)) + }) + } + } + } + AppNavHost( + navController, + settingsModel, + clockModel, + modifier = Modifier + .fillMaxSize() + ) + } } } diff --git a/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt b/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt index 9d0cbdf0..98cf8810 100644 --- a/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt +++ b/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt @@ -1,21 +1,27 @@ package com.bnyro.clock.ui.screens +import android.content.Context +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Pause @@ -23,17 +29,21 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.LargeFloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -43,154 +53,270 @@ import com.bnyro.clock.extensions.addZero import com.bnyro.clock.obj.WatchState import com.bnyro.clock.ui.model.StopwatchModel import com.bnyro.clock.ui.nav.TopBarScaffold -import com.bnyro.clock.util.TimeHelper +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) { val context = LocalContext.current - + val orientation = LocalConfiguration.current.orientation val scope = rememberCoroutineScope() val timeStampsState = rememberLazyListState() TopBarScaffold(title = stringResource(R.string.stopwatch), onClickSettings) { pv -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(pv), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(pv), + horizontalAlignment = Alignment.CenterHorizontally ) { - Box( + TimeDisplay( modifier = Modifier - .size(320.dp), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.Bottom - ) { - val minutes = stopwatchModel.currentPosition / 60000 - val seconds = - (stopwatchModel.currentPosition % 60000) / 1000 - val hundreds = - stopwatchModel.currentPosition % 1000 / 10 - - Text( - text = minutes.toString(), - style = MaterialTheme.typography.displayLarge - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = seconds.addZero(), - style = MaterialTheme.typography.displayLarge - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = hundreds.addZero(), - style = MaterialTheme.typography.headlineMedium - ) - } - } - CircularProgressIndicator( - progress = (stopwatchModel.currentPosition % 60000) / 60000f, - modifier = Modifier.size(320.dp), - trackColor = MaterialTheme.colorScheme.surfaceVariant, - strokeWidth = 12.dp, - strokeCap = StrokeCap.Round + .padding(16.dp) + .weight(2f), + innerModifier = Modifier + .heightIn(0.dp, 320.dp) + .fillMaxWidth(), stopwatchModel ) - } - AnimatedVisibility(stopwatchModel.rememberedTimeStamps.isNotEmpty()) { - LazyColumn( - modifier = Modifier - .height(100.dp) - .padding(bottom = 30.dp), - state = timeStampsState - ) { - itemsIndexed(stopwatchModel.rememberedTimeStamps) { index, timeStamp -> - val time = TimeHelper.millisToTime(timeStamp.toLong()) - Row( - modifier = Modifier - .fillMaxWidth(0.6f) - .padding(vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("#${index + 1}") - Text(time.toString()) - } - } + AnimatedVisibility(stopwatchModel.rememberedTimeStamps.isNotEmpty()) { + LapTable( + modifier = Modifier + .heightIn(0.dp, 300.dp) + .fillMaxWidth(0.8f) + .padding(bottom = 30.dp), stopwatchModel, timeStampsState + ) } + StopwatchController(stopwatchModel, scope, timeStampsState, context) } + } else { Row( modifier = Modifier - .padding(bottom = 16.dp), + .fillMaxSize() + .padding(pv), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - AnimatedVisibility(stopwatchModel.state == WatchState.RUNNING) { - Row { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { - stopwatchModel.rememberedTimeStamps.add( - stopwatchModel.currentPosition - ) - scope.launch { - timeStampsState.scrollToItem( - stopwatchModel.rememberedTimeStamps.size - 1 - ) - } - } - ) { - Icon(Icons.Default.Timer, null) - } - Spacer(modifier = Modifier.width(20.dp)) - } + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + TimeDisplay( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + innerModifier = Modifier + .fillMaxSize(), + stopwatchModel = stopwatchModel, + showProgress = false + ) + StopwatchController( + stopwatchModel = stopwatchModel, + scope = scope, + timeStampsState = timeStampsState, + context = context + ) } - LargeFloatingActionButton( - shape = CircleShape, + AnimatedVisibility(stopwatchModel.rememberedTimeStamps.isNotEmpty()) { + LapTable( + modifier = Modifier + .width(320.dp) + .fillMaxHeight() + .padding(8.dp), + stopwatchModel = stopwatchModel, + timeStampsState = timeStampsState + ) + } + } + } + } + if (stopwatchModel.state == WatchState.RUNNING) { + KeepScreenOn() + } +} + +@Composable +private fun StopwatchController( + stopwatchModel: StopwatchModel, + scope: CoroutineScope, + timeStampsState: LazyListState, + context: Context +) { + Row( + modifier = Modifier + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility(stopwatchModel.state == WatchState.RUNNING) { + Row { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, onClick = { - stopwatchModel.pauseResumeStopwatch(context) + stopwatchModel.onLapClicked() + scope.launch { + timeStampsState.scrollToItem( + stopwatchModel.rememberedTimeStamps.size - 1 + ) + } } ) { - Icon( - imageVector = if (stopwatchModel.state == WatchState.RUNNING) { - Icons.Default.Pause - } else { - Icons.Default.PlayArrow - }, - contentDescription = null - ) + Icon(Icons.Default.Timer, null) } - AnimatedVisibility(stopwatchModel.currentPosition != 0) { - Row { - Spacer(modifier = Modifier.width(20.dp)) - if (stopwatchModel.state != WatchState.PAUSED) { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { stopwatchModel.stopStopwatch(context) } - ) { - Icon(Icons.Default.Stop, null) - } - } else { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { - stopwatchModel.stopStopwatch(context) - stopwatchModel.rememberedTimeStamps.clear() - } - ) { - Icon(Icons.Default.Delete, null) - } + Spacer(modifier = Modifier.width(20.dp)) + } + } + LargeFloatingActionButton( + shape = CircleShape, + onClick = { + stopwatchModel.pauseResumeStopwatch(context) + } + ) { + Icon( + imageVector = if (stopwatchModel.state == WatchState.RUNNING) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + }, + contentDescription = null + ) + } + AnimatedVisibility(stopwatchModel.currentPosition != 0) { + Row { + Spacer(modifier = Modifier.width(20.dp)) + if (stopwatchModel.state != WatchState.PAUSED) { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + onClick = { stopwatchModel.stopStopwatch(context) } + ) { + Icon(Icons.Default.Stop, null) + } + } else { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + onClick = { + stopwatchModel.stopStopwatch(context) + stopwatchModel.rememberedTimeStamps.clear() } + ) { + Icon(Icons.Default.Delete, null) } } } } } - if (stopwatchModel.state == WatchState.RUNNING) { - KeepScreenOn() +} + +@Composable +private fun LapTable( + modifier: Modifier = Modifier, + stopwatchModel: StopwatchModel, + timeStampsState: LazyListState +) { + LazyColumn( + modifier = modifier + .clip( + RoundedCornerShape(16.dp) + ) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) + .padding(16.dp), + state = timeStampsState + ) { + item { + Column { + Row { + Text( + text = stringResource(R.string.lap), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.lap_time), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.overall_time), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium + ) + } + Divider() + } + } + itemsIndexed(stopwatchModel.rememberedTimeStamps) { index, time -> + Row( + modifier = Modifier + .padding(vertical = 6.dp) + ) { + Text( + String.format("%02d", index + 1), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + time.second.toFullString(), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + time.first.toFullString(), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + +} + +@Composable +private fun TimeDisplay( + modifier: Modifier = Modifier, + innerModifier: Modifier, + stopwatchModel: StopwatchModel, + showProgress: Boolean = true +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = innerModifier, + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.Bottom + ) { + val minutes = stopwatchModel.currentPosition / 60000 + val seconds = + (stopwatchModel.currentPosition % 60000) / 1000 + val hundreds = + stopwatchModel.currentPosition % 1000 / 10 + + Text( + text = minutes.toString(), + style = MaterialTheme.typography.displayLarge + ) + Text(text = ":", style = MaterialTheme.typography.displayLarge) + Text( + text = seconds.addZero(), + style = MaterialTheme.typography.displayLarge + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = hundreds.addZero(), + style = MaterialTheme.typography.headlineMedium + ) + } + } + if (showProgress) { + CircularProgressIndicator( + progress = (stopwatchModel.currentPosition % 60000) / 60000f, + modifier = Modifier + .heightIn(0.dp, 320.dp) + .aspectRatio(1f, true), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + strokeWidth = 12.dp, + strokeCap = StrokeCap.Round + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a78a5795..3b1f1cf7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,4 +105,7 @@ T F S + Lap + Lap Time + Overall Time \ No newline at end of file