More compose improvements

- better navigation animations
- better duration input
- deprecation fixes
This commit is contained in:
William Brawner 2023-12-17 13:53:06 -07:00
parent e1d5ce0873
commit 7238755e99
Signed by: wbrawner
GPG key ID: 8FF12381C6C90D35
20 changed files with 323 additions and 100 deletions

View file

@ -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>

View file

@ -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$" />

View 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
View 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>

View file

@ -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>

View file

@ -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)

View file

@ -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())
}

View file

@ -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(

View file

@ -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")
}
}

View file

@ -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())
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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 = {

View file

@ -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>

View file

@ -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),
)
}
}

View file

@ -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"),
)
}
}

View file

@ -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"),
)
}
}

View file

@ -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" }

View file

@ -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

View file

@ -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)
}