diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/HomeScreen.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/HomeScreen.kt index 5bff446..bf11700 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/HomeScreen.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/HomeScreen.kt @@ -1,7 +1,9 @@ package im.molly.monero.demo.ui import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState @@ -12,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import im.molly.monero.demo.ui.component.LoadingWheel import im.molly.monero.demo.ui.component.Toolbar @Composable @@ -66,10 +69,16 @@ private fun HomeScreen( private fun LazyListScope.walletCards( walletListUiState: WalletListUiState, onWalletClick: (Long) -> Unit, + modifier: Modifier = Modifier, ) { when (walletListUiState) { WalletListUiState.Loading -> item { - Text(text = "Loading wallet list...") // TODO + LoadingWheel( + modifier = modifier + .fillMaxWidth() + .wrapContentSize(), + contentDesc = "Loading wallets", + ) } is WalletListUiState.Loaded -> { diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletCard.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletCard.kt index 36a1cfe..d510af0 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletCard.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletCard.kt @@ -4,6 +4,7 @@ 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.foundation.layout.wrapContentSize import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -14,6 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import im.molly.monero.demo.ui.component.LoadingWheel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,7 +79,12 @@ fun WalletCardError() { @Composable fun WalletCardLoading() { - Text(text = "Loading wallet...") // TODO + LoadingWheel( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(), + contentDesc = "Loading wallet", + ) } //@Preview diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/LoadingWheel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/LoadingWheel.kt new file mode 100644 index 0000000..89f50b6 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/LoadingWheel.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.molly.monero.demo.ui.component + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import im.molly.monero.demo.ui.theme.AppTheme +import kotlinx.coroutines.launch + +@Composable +fun LoadingWheel( + contentDesc: String, + modifier: Modifier = Modifier, +) { + val infiniteTransition = rememberInfiniteTransition(label = "wheel transition") + + // Specifies the float animation for slowly drawing out the lines on entering + val startValue = if (LocalInspectionMode.current) 0F else 1F + val floatAnimValues = (0 until NUM_OF_LINES).map { remember { Animatable(startValue) } } + LaunchedEffect(floatAnimValues) { + (0 until NUM_OF_LINES).map { index -> + launch { + floatAnimValues[index].animateTo( + targetValue = 0F, + animationSpec = tween( + durationMillis = 100, + easing = FastOutSlowInEasing, + delayMillis = 40 * index, + ), + ) + } + } + } + + // Specifies the rotation animation of the entire Canvas composable + val rotationAnim by infiniteTransition.animateFloat( + initialValue = 0F, + targetValue = 360F, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing), + ), + label = "wheel rotation animation", + ) + + // Specifies the color animation for the base-to-progress line color change + val baseLineColor = MaterialTheme.colorScheme.onBackground + val progressLineColor = MaterialTheme.colorScheme.inversePrimary + + val colorAnimValues = (0 until NUM_OF_LINES).map { index -> + infiniteTransition.animateColor( + initialValue = baseLineColor, + targetValue = baseLineColor, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = ROTATION_TIME / 2 + progressLineColor at ROTATION_TIME / NUM_OF_LINES / 2 with LinearEasing + baseLineColor at ROTATION_TIME / NUM_OF_LINES with LinearEasing + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index), + ), + label = "wheel color animation", + ) + } + + // Draws out the LoadingWheel Canvas composable and sets the animations + Canvas( + modifier = modifier + .size(48.dp) + .padding(8.dp) + .graphicsLayer { rotationZ = rotationAnim } + .semantics { contentDescription = contentDesc } + .testTag("loadingWheel"), + ) { + repeat(NUM_OF_LINES) { index -> + rotate(degrees = index * 30f) { + drawLine( + color = colorAnimValues[index].value, + // Animates the initially drawn 1 pixel alpha from 0 to 1 + alpha = if (floatAnimValues[index].value < 1f) 1f else 0f, + strokeWidth = 4F, + cap = StrokeCap.Round, + start = Offset(size.width / 2, size.height / 4), + end = Offset(size.width / 2, floatAnimValues[index].value * size.height / 4), + ) + } + } + } +} + +@Composable +fun OverlayLoadingWheel( + contentDesc: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(60.dp), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.83f), + modifier = modifier + .size(60.dp), + ) { + LoadingWheel( + contentDesc = contentDesc, + ) + } +} + +@Preview +@Composable +fun LoadingWheelPreview() { + AppTheme { + Surface { + LoadingWheel(contentDesc = "LoadingWheel") + } + } +} + +@Preview +@Composable +fun OverlayLoadingWheelPreview() { + AppTheme { + Surface { + OverlayLoadingWheel(contentDesc = "LoadingWheel") + } + } +} + +private const val ROTATION_TIME = 12000 +private const val NUM_OF_LINES = 12