More compose improvements
- better navigation animations - better duration input - deprecation fixes
This commit is contained in:
parent
e1d5ce0873
commit
7238755e99
20 changed files with 323 additions and 100 deletions
|
@ -4,6 +4,7 @@
|
|||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
|
@ -117,7 +118,6 @@
|
|||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="WRAP_ON_TYPING" value="1" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
|
@ -4,9 +4,8 @@
|
|||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
|
4
.idea/kotlinScripting.xml
Normal file
4
.idea/kotlinScripting.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinScriptingSettings" supportWarning="false" />
|
||||
</project>
|
6
.idea/kotlinc.xml
Normal file
6
.idea/kotlinc.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.10" />
|
||||
</component>
|
||||
</project>
|
|
@ -1,4 +1,5 @@
|
|||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
|
|
|
@ -77,7 +77,7 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
wearApp(project(":wear"))
|
||||
// wearApp(project(":wear"))
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.splash)
|
||||
|
|
|
@ -12,6 +12,7 @@ import androidx.core.math.MathUtils
|
|||
import com.wbrawner.trainterval.shared.IntervalDuration
|
||||
import com.wbrawner.trainterval.shared.toIntervalDuration
|
||||
import com.wbrawner.trainterval.shared.toMillis
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val MIN_DURATION = 0L
|
||||
|
@ -25,8 +26,7 @@ class DurationPicker @JvmOverloads constructor(
|
|||
@StyleRes defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private var intervalDuration =
|
||||
IntervalDuration()
|
||||
private var intervalDuration = IntervalDuration.create()
|
||||
var duration: Long
|
||||
get() = intervalDuration.toMillis()
|
||||
set(value) {
|
||||
|
@ -47,7 +47,7 @@ class DurationPicker @JvmOverloads constructor(
|
|||
hours.maxValue = 23
|
||||
hours.setOnValueChangedListener { _, _, newVal ->
|
||||
intervalDuration = intervalDuration.copy(hours = newVal.toLong())
|
||||
Log.v("DurationPicker", "Updated hours: $newVal")
|
||||
Timber.v("DurationPicker", "Updated hours: $newVal")
|
||||
}
|
||||
hours.setFormatter(numberFormatter())
|
||||
minutes = view.findViewById(R.id.minutes)
|
||||
|
@ -55,7 +55,7 @@ class DurationPicker @JvmOverloads constructor(
|
|||
minutes.maxValue = 59
|
||||
minutes.setOnValueChangedListener { _, _, newVal ->
|
||||
intervalDuration = intervalDuration.copy(minutes = newVal.toLong())
|
||||
Log.v("DurationPicker", "Updated minutes: $newVal")
|
||||
Timber.v("DurationPicker", "Updated minutes: $newVal")
|
||||
}
|
||||
minutes.setFormatter(numberFormatter())
|
||||
seconds = view.findViewById(R.id.seconds)
|
||||
|
@ -63,7 +63,7 @@ class DurationPicker @JvmOverloads constructor(
|
|||
seconds.maxValue = 59
|
||||
seconds.setOnValueChangedListener { _, _, newVal ->
|
||||
intervalDuration = intervalDuration.copy(seconds = newVal.toLong())
|
||||
Log.v("DurationPicker", "Updated seconds: $newVal")
|
||||
Timber.v("DurationPicker", "Updated seconds: $newVal")
|
||||
}
|
||||
seconds.setFormatter(numberFormatter())
|
||||
}
|
||||
|
|
|
@ -7,6 +7,11 @@ import android.view.WindowInsetsController
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
|
@ -41,7 +46,22 @@ class MainActivity : AppCompatActivity() {
|
|||
val navController = rememberNavController()
|
||||
TraintervalTheme {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
NavHost(navController, startDestination = "timers") {
|
||||
NavHost(
|
||||
navController,
|
||||
startDestination = "timers",
|
||||
enterTransition = {
|
||||
fadeIn() + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up)
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut() + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down)
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn()
|
||||
},
|
||||
popExitTransition = {
|
||||
fadeOut() + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down)
|
||||
}
|
||||
) {
|
||||
composable("new") {
|
||||
TimerFormScreen(timerFormViewModel, navController)
|
||||
}
|
||||
|
@ -51,7 +71,19 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
composable(
|
||||
"timers/{timerId}",
|
||||
route = "timers/{timerId}",
|
||||
enterTransition = {
|
||||
fadeIn() + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start)
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut() + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End)
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn()
|
||||
},
|
||||
popExitTransition = {
|
||||
fadeOut() + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End)
|
||||
},
|
||||
arguments = listOf(navArgument("timerId") {
|
||||
type = NavType.LongType
|
||||
})
|
||||
|
@ -59,7 +91,7 @@ class MainActivity : AppCompatActivity() {
|
|||
ActiveTimerScreen(
|
||||
hiltViewModel(),
|
||||
navController::navigateUp,
|
||||
backStackEntry.arguments?.getLong("timerId")?: 0L
|
||||
backStackEntry.arguments?.getLong("timerId") ?: 0L
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
package com.wbrawner.trainterval
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import timber.log.Timber
|
||||
|
||||
private const val TAG = "ActivityLifecycle"
|
||||
|
||||
class TraintervalActivityLifecycleCallbacks(
|
||||
private val logger: Timber.Tree = Timber.tag(TAG)
|
||||
) : Application.ActivityLifecycleCallbacks {
|
||||
private var currentActivity: Activity? = null
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
logger.v("onActivityCreated: $activity")
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
logger.v("onActivityStarted: $activity")
|
||||
currentActivity = activity
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
logger.v("onActivityResumed: $activity")
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
logger.v("onActivityPaused: $activity")
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
logger.v("onActivityStopped: $activity")
|
||||
currentActivity = null
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
logger.v("onActivityDestroyed: $activity")
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
|
||||
logger.v("onActivitySaveInstanceState: $activity")
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import timber.log.Timber
|
||||
import javax.inject.Singleton
|
||||
|
||||
@HiltAndroidApp
|
||||
|
@ -16,8 +17,9 @@ class TraintervalApplication : Application() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val lifecycleCallbacks = TraintervalActivityLifecycleCallbacks()
|
||||
registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
@ -143,7 +144,7 @@ fun ActiveTimerScreen(
|
|||
}
|
||||
Text(text = text, color = contentColor)
|
||||
},
|
||||
colors = smallTopAppBarColors(containerColor = Color.Transparent),
|
||||
colors = topAppBarColors(containerColor = Color.Transparent),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onUpNavigation
|
||||
|
|
|
@ -20,9 +20,8 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ActiveTimerViewModel(
|
||||
private val timerDao: IntervalTimerDao,
|
||||
private val logger: Timber.Tree
|
||||
class ActiveTimerViewModel @Inject constructor(
|
||||
private val timerDao: IntervalTimerDao
|
||||
) : ViewModel() {
|
||||
val timerState: MutableStateFlow<IntervalTimerState> = MutableStateFlow(LoadingState)
|
||||
val effects: MutableSharedFlow<IntervalTimerEffects> = MutableSharedFlow()
|
||||
|
@ -33,13 +32,9 @@ class ActiveTimerViewModel(
|
|||
private var currentSet = 1
|
||||
private var currentRound = 1
|
||||
private var timeRemaining: Long = 0
|
||||
|
||||
@Inject
|
||||
constructor(timerDao: IntervalTimerDao) : this(timerDao, Timber.tag("ActiveTimerViewModel"))
|
||||
|
||||
fun loadTimer(timerId: Long) {
|
||||
if (timerJob == null || timer.id != timerId) {
|
||||
logger.d("Initializing with Timer id $timerId")
|
||||
Timber.d("Initializing with Timer id $timerId")
|
||||
viewModelScope.launch {
|
||||
timer = timerDao.getById(timerId)
|
||||
currentSet = timer.sets
|
||||
|
|
|
@ -3,20 +3,27 @@ package com.wbrawner.trainterval.timerform
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.trainterval.shared.IntervalDuration
|
||||
import com.wbrawner.trainterval.shared.IntervalTimer
|
||||
import com.wbrawner.trainterval.shared.shiftLeft
|
||||
import com.wbrawner.trainterval.shared.shiftRight
|
||||
import com.wbrawner.trainterval.shared.toIntervalDuration
|
||||
import com.wbrawner.trainterval.shared.toSeconds
|
||||
|
||||
|
@ -40,9 +47,10 @@ fun TimerFormScreen(
|
|||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Cancel")
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = "Cancel")
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
@ -105,12 +113,22 @@ fun TimerForm(
|
|||
value = title,
|
||||
onValueChange = setTitle,
|
||||
label = { Text("Title") },
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = description,
|
||||
onValueChange = setDescription,
|
||||
label = { Text("Description") },
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
)
|
||||
DurationInput(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
@ -153,6 +171,23 @@ fun TimerForm(
|
|||
value = cycles,
|
||||
onValueChange = setCycles,
|
||||
label = "Cycles",
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions {
|
||||
saveTimer(
|
||||
title,
|
||||
description,
|
||||
warmUp,
|
||||
lowIntensity,
|
||||
highIntensity,
|
||||
rest,
|
||||
coolDown,
|
||||
sets,
|
||||
cycles
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
@ -175,7 +210,6 @@ fun TimerForm(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DurationInput(
|
||||
modifier: Modifier,
|
||||
|
@ -183,20 +217,30 @@ fun DurationInput(
|
|||
value: Long,
|
||||
onValueChange: (Long) -> Unit
|
||||
) {
|
||||
val (input, setInput) = remember { mutableStateOf(value.toIntervalDuration().toString()) }
|
||||
val (input, setInput) = remember { mutableStateOf(TextFieldValue(value.toIntervalDuration().toStringFull())) }
|
||||
val (isError, setError) = remember { mutableStateOf(false) }
|
||||
// TODO: Use a triple spinner instead of the text field
|
||||
OutlinedTextField(
|
||||
modifier = modifier,
|
||||
value = input,
|
||||
onValueChange = {
|
||||
setInput(it)
|
||||
IntervalDuration.parse(it)
|
||||
val parts = it.text.split(':').takeLast(3)
|
||||
val durationString = when (parts.last().length) {
|
||||
1 -> it.text.shiftRight()
|
||||
2 -> it.text
|
||||
3 -> it.text.shiftLeft()
|
||||
else -> parts.joinToString(":")
|
||||
}
|
||||
IntervalDuration.parse(durationString)
|
||||
?.let { duration ->
|
||||
setError(false)
|
||||
val newDurationString = duration.toStringFull()
|
||||
setInput(it.copy(text = newDurationString, selection = TextRange(newDurationString.length)))
|
||||
onValueChange(duration.toSeconds())
|
||||
}
|
||||
?: setError(true)
|
||||
?: run {
|
||||
setInput(it.copy(text = durationString, selection = TextRange(durationString.length)))
|
||||
setError(true)
|
||||
}
|
||||
},
|
||||
label = { Text(label) },
|
||||
isError = isError,
|
||||
|
@ -207,17 +251,26 @@ fun DurationInput(
|
|||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NumberInput(
|
||||
modifier: Modifier,
|
||||
label: String,
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit
|
||||
onValueChange: (Int) -> Unit,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default
|
||||
) {
|
||||
val (input, setInput) = remember { mutableStateOf(value.toString()) }
|
||||
val (isError, setError) = remember { mutableStateOf(false) }
|
||||
|
@ -234,7 +287,9 @@ fun NumberInput(
|
|||
}
|
||||
?: setError(true)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||
maxLines = 1,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
label = { Text(label) },
|
||||
isError = isError,
|
||||
supportingText = {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:statusBarColor">#00000000</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:navigationBarColor">#00000000</item>
|
||||
<item name="android:windowContentTransitions">true</item>
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package com.wbrawner.trainterval.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class IntervalDurationParseTest(
|
||||
private val durationString: String,
|
||||
private val expectedDuration: IntervalDuration?
|
||||
) {
|
||||
|
||||
@Test
|
||||
fun parseTest() {
|
||||
assertEquals(expectedDuration, IntervalDuration.parse(durationString))
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "duration string \"{0}\"")
|
||||
fun data() = arrayListOf<Array<Any?>>(
|
||||
arrayOf("", IntervalDuration.create()),
|
||||
arrayOf(":", IntervalDuration.create()),
|
||||
arrayOf("a", null),
|
||||
arrayOf("a:b", null),
|
||||
arrayOf("0", IntervalDuration.create()),
|
||||
arrayOf("00", IntervalDuration.create()),
|
||||
arrayOf(":00", IntervalDuration.create()),
|
||||
arrayOf("000", IntervalDuration.create()),
|
||||
arrayOf("00:00", IntervalDuration.create()),
|
||||
arrayOf("00:00:00", IntervalDuration.create()),
|
||||
arrayOf("1", IntervalDuration.create(seconds = 1)),
|
||||
arrayOf("01", IntervalDuration.create(seconds = 1)),
|
||||
arrayOf(":01", IntervalDuration.create(seconds = 1)),
|
||||
arrayOf("10:01", IntervalDuration.create(minutes = 10, seconds = 1)),
|
||||
arrayOf(":10:01", IntervalDuration.create(minutes = 10, seconds = 1)),
|
||||
arrayOf("01:01:01", IntervalDuration.create(hours = 1, minutes = 1, seconds = 1)),
|
||||
arrayOf("1:1:1", IntervalDuration.create(hours = 1, minutes = 1, seconds = 1)),
|
||||
arrayOf("001:001:001", IntervalDuration.create(hours = 1, minutes = 1, seconds = 1)),
|
||||
arrayOf("999:10:01", IntervalDuration.create(hours = 999, minutes = 10, seconds = 1)),
|
||||
arrayOf("999:10:999", null),
|
||||
arrayOf("999:999:999", null),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.wbrawner.trainterval.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class LeftShiftTest(
|
||||
private val durationString: String,
|
||||
private val expectedLeftShift: String
|
||||
) {
|
||||
|
||||
@Test
|
||||
fun leftShiftTest() {
|
||||
assertEquals(expectedLeftShift, durationString.shiftLeft())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "duration string \"{0}\"")
|
||||
fun data() = arrayListOf<Array<Any?>>(
|
||||
arrayOf("00:00:00", "00:00:00"),
|
||||
arrayOf("11:11:11", "11:11:11"),
|
||||
arrayOf("00:00:001", "00:00:01"),
|
||||
arrayOf("01:23:456", "12:34:56"),
|
||||
arrayOf("12:34:567", "123:45:67"),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.wbrawner.trainterval.shared
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class RightShiftTest(
|
||||
private val durationString: String,
|
||||
private val expectedRightShift: String
|
||||
) {
|
||||
|
||||
@Test
|
||||
fun leftShiftTest() {
|
||||
assertEquals(expectedRightShift, durationString.shiftRight())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "duration string \"{0}\"")
|
||||
fun data() = arrayListOf<Array<Any?>>(
|
||||
arrayOf("00:00:00", "00:00:00"),
|
||||
arrayOf("11:11:1", "01:11:11"),
|
||||
arrayOf("00:00:0", "00:00:00"),
|
||||
arrayOf("12:34:5", "01:23:45"),
|
||||
arrayOf("123:45:6", "12:34:56"),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ versionCode = "1"
|
|||
versionName = "1.0"
|
||||
|
||||
[libraries]
|
||||
android-gradle = { module = "com.android.tools.build:gradle", version = "8.1.2" }
|
||||
android-gradle = { module = "com.android.tools.build:gradle", version = "8.1.4" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
|
||||
|
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,6 +1,6 @@
|
|||
#Tue Jan 19 11:02:45 MST 2021
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
|
||||
distributionUrl=https://services.gradle.org/distributions/gradle-8.4-all.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
@ -1,32 +1,57 @@
|
|||
package com.wbrawner.trainterval.shared
|
||||
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
data class IntervalDuration(
|
||||
val hours: Long = 0,
|
||||
val minutes: Long = 0,
|
||||
val seconds: Long = 0
|
||||
data class IntervalDuration private constructor(
|
||||
val hours: Long,
|
||||
val minutes: Long,
|
||||
val seconds: Long
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return if (hours > 0) "%02d:%02d:%02d".format(hours, minutes, seconds)
|
||||
else "%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
|
||||
fun toStringFull() = "%02d:%02d:%02d".format(hours, minutes, seconds)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
hours: Long = 0,
|
||||
minutes: Long = 0,
|
||||
seconds: Long = 0
|
||||
): IntervalDuration {
|
||||
if (minutes > 59) {
|
||||
throw IllegalArgumentException("Invalid value for minutes: $minutes")
|
||||
}
|
||||
if (seconds > 59) {
|
||||
throw IllegalArgumentException("Invalid value for seconds: $seconds")
|
||||
}
|
||||
return IntervalDuration(hours, minutes, seconds)
|
||||
}
|
||||
|
||||
fun parse(s: String): IntervalDuration? {
|
||||
val parts = s.split(":").map { it.toLongOrNull() }
|
||||
if (parts.size !in 1..3 || parts.any { it == null }) {
|
||||
val parts = s.split(":")
|
||||
if (parts.any { it.trim().matches(Regex("\\D")) }) {
|
||||
return null
|
||||
}
|
||||
return when (parts.size) {
|
||||
1 -> IntervalDuration(seconds = parts[0]!!)
|
||||
2 -> IntervalDuration(minutes = parts[0]!!, seconds = parts[1]!!)
|
||||
else -> IntervalDuration(
|
||||
hours = parts[0]!!,
|
||||
minutes = parts[1]!!,
|
||||
seconds = parts[2]!!,
|
||||
)
|
||||
val numbers = parts.map { it.toLongOrNull() ?: 0 }
|
||||
if (numbers.size !in 1..3) {
|
||||
return null
|
||||
}
|
||||
return when (numbers.size) {
|
||||
1 -> create(seconds = numbers.first())
|
||||
2 -> create(minutes = numbers[0], seconds = numbers[1])
|
||||
else -> try {
|
||||
create(
|
||||
hours = numbers[0],
|
||||
minutes = numbers[1],
|
||||
seconds = numbers[2],
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e("Failed to parse $s", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -61,9 +86,49 @@ fun Long.toIntervalDuration(): IntervalDuration {
|
|||
seconds -= minutes * SECONDS_IN_MINUTE
|
||||
}
|
||||
|
||||
return IntervalDuration(
|
||||
return requireNotNull(IntervalDuration.create(
|
||||
hours,
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fun String.shiftLeft(): String {
|
||||
val chars = toCharArray()
|
||||
if (chars.lastIndexOf(':') == chars.lastIndex - 2) {
|
||||
return this
|
||||
}
|
||||
for (i in chars.lastIndex downTo 0) {
|
||||
if (chars[i] == ':') {
|
||||
chars.swap(i, i + 1)
|
||||
}
|
||||
}
|
||||
return if (chars.first() == '0' && chars.indexOf(':') == 3) {
|
||||
chars.sliceArray(1..chars.lastIndex)
|
||||
} else {
|
||||
chars
|
||||
}.concatToString()
|
||||
}
|
||||
|
||||
fun String.shiftRight(): String {
|
||||
val chars = toCharArray()
|
||||
if (chars.lastIndexOf(':') == chars.lastIndex - 2) {
|
||||
return this
|
||||
}
|
||||
for (i in 0..chars.lastIndex) {
|
||||
if (chars[i] == ':') {
|
||||
chars.swap(i, i - 1)
|
||||
}
|
||||
}
|
||||
return if (chars.indexOf(':') == 1) {
|
||||
CharArray(1) {'0'} + chars
|
||||
} else {
|
||||
chars
|
||||
}.concatToString()
|
||||
}
|
||||
|
||||
fun CharArray.swap(firstIndex: Int, secondIndex: Int) {
|
||||
val char = get(firstIndex)
|
||||
set(firstIndex, get(secondIndex))
|
||||
set(secondIndex, char)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue