diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 5e3f977..7aa318d 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -16,6 +16,9 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/MediaStorePage.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/MediaStorePage.kt index ba85c5f..cb7eb49 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/MediaStorePage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/MediaStorePage.kt @@ -53,7 +53,8 @@ fun MediaStorePage( is ResourceState.Loading -> LoadingPage(text = stringResource(R.string.loading_mediastore)) is ResourceState.Error -> ErrorPage( - error = songsList.message ?: "Unknown" + modifier = Modifier.fillMaxSize(), + throwable = Exception(songsList.message ?: "Unknown") ) { onReloadMediaStore() } is ResourceState.Success -> { diff --git a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/utilities/tageditor/MetadataEditorPage.kt b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/utilities/tageditor/MetadataEditorPage.kt index 01d0a8d..ce12a88 100644 --- a/app/src/main/java/com/bobbyesp/metadator/presentation/pages/utilities/tageditor/MetadataEditorPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/presentation/pages/utilities/tageditor/MetadataEditorPage.kt @@ -247,7 +247,10 @@ fun MetadataEditorPage( .navigationBarsPadding() ) { state -> when (state) { - is ScreenState.Error -> ErrorPage(error = state.exception.stackTrace.toString()) { + is ScreenState.Error -> ErrorPage( + modifier = Modifier.fillMaxSize(), + throwable = state.exception + ) { onEvent(MetadataEditorVM.Event.LoadMetadata(receivedAudio.localPath)) } diff --git a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt index 8356988..16c8454 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt @@ -1,66 +1,402 @@ package com.bobbyesp.ui.common.pages import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith 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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material.icons.twotone.BugReport import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.bobbyesp.ui.R +import com.bobbyesp.ui.components.button.BackButton +import com.bobbyesp.ui.motion.DefaultBoundsTransform +import com.bobbyesp.ui.motion.EmphasizedAccelerateEasing +import com.bobbyesp.ui.motion.EmphasizedDecelerateEasing +import com.bobbyesp.ui.motion.EmphasizedEasing +import com.bobbyesp.ui.motion.MotionConstants.DURATION +import com.bobbyesp.ui.motion.MotionConstants.DURATION_ENTER +import com.bobbyesp.ui.motion.MotionConstants.DURATION_EXIT_SHORT +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ErrorPage( + modifier: Modifier = Modifier, throwable: Throwable, onRetry: () -> Unit +) { + var showFullscreenError by remember { mutableStateOf(false) } + SharedTransitionLayout( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + AnimatedContent( + transitionSpec = { + fadeIn( + tween( + durationMillis = DURATION_ENTER, + delayMillis = DURATION_EXIT_SHORT, + easing = EmphasizedDecelerateEasing + ) + ) togetherWith fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, easing = EmphasizedAccelerateEasing + ) + ) using SizeTransform { _, _ -> + tween(durationMillis = DURATION, easing = EmphasizedEasing) + } + }, targetState = showFullscreenError, label = "Error Page animated content transition" + ) { wantsFullscreen -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (wantsFullscreen) { + ExpandedErrorPage( + modifier = modifier, + throwable = throwable, + animatedVisibilityScope = this@AnimatedContent, + onMinimize = { + showFullscreenError = false + } + ) + } else { + MinimizedErrorPage( + modifier = modifier, + throwable = throwable, + animatedVisibilityScope = this@AnimatedContent, + onCardClicked = { + showFullscreenError = true + }, + onRetry = onRetry + ) + } + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun SharedTransitionScope.MinimizedErrorPage( + modifier: Modifier = Modifier, + throwable: Throwable, + animatedVisibilityScope: AnimatedVisibilityScope, + onCardClicked: () -> Unit, + onRetry: () -> Unit, +) { + OutlinedCard( + modifier = Modifier, + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + shape = MaterialTheme.shapes.small + ) { + Column( + modifier = modifier + .padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Rounded.WarningAmber, + contentDescription = stringResource(id = R.string.error), + ) + Text( + text = stringResource(id = R.string.unknown_error_title), + style = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onBackground + ), + fontWeight = FontWeight.SemiBold, + ) + PrimaryStacktraceCard( + modifier = Modifier + .sharedBounds( + boundsTransform = DefaultBoundsTransform, + enter = fadeIn( + tween( + durationMillis = DURATION_ENTER, + delayMillis = DURATION_EXIT_SHORT, + easing = EmphasizedDecelerateEasing + ) + ), + exit = fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, + easing = EmphasizedAccelerateEasing + ) + ), + sharedContentState = rememberSharedContentState(key = "stacktraceCardBounds"), + animatedVisibilityScope = animatedVisibilityScope, + placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth(), + errorType = throwable::class.simpleName + ?: stringResource(id = R.string.unknown_error_title), + methodFailed = throwable.localizedMessage + ?: stringResource(id = R.string.unknown_error_title), + line = throwable.stackTrace.firstOrNull()?.lineNumber ?: 0, + onClick = onCardClicked + ) + Button(onClick = onRetry) { + Text(text = stringResource(id = R.string.retry)) + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun SharedTransitionScope.ExpandedErrorPage( modifier: Modifier = Modifier, - error: String, - onRetry: () -> Unit + animatedVisibilityScope: AnimatedVisibilityScope, + throwable: Throwable, + onMinimize: () -> Unit, ) { + BackHandler { + onMinimize() + } Column( modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(16.dp), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally + .sharedBounds( + boundsTransform = DefaultBoundsTransform, + enter = fadeIn( + tween( + durationMillis = DURATION_ENTER, + delayMillis = DURATION_EXIT_SHORT, + easing = EmphasizedDecelerateEasing + ) + ), + exit = fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, easing = EmphasizedAccelerateEasing + ) + ), + sharedContentState = rememberSharedContentState(key = "stacktraceCardBounds"), + animatedVisibilityScope = animatedVisibilityScope, + placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, + ) + .fillMaxSize(), ) { - Icon( - imageVector = Icons.Rounded.ErrorOutline, - contentDescription = stringResource(id = R.string.error) - ) - Text( - text = stringResource(id = R.string.unknown_error_title), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.SemiBold + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .systemBarsPadding() + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + BackButton( + onClick = { + onMinimize() + } + ) + Text( + modifier = Modifier, + text = stringResource(id = R.string.unknown_error_title), + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis + ) + } + } + StackTraceViewer( + modifier = Modifier + .fillMaxWidth(), + throwable = throwable ) - Spacer(modifier = Modifier.padding(8.dp)) - Text(text = error) - Button(onClick = onRetry) { - Text(text = stringResource(id = R.string.retry)) + } +} + +@Composable +fun StackTraceViewer( + modifier: Modifier = Modifier, + throwable: Throwable +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + throwable.stackTrace.forEachIndexed { index, element -> + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .width(32.dp) + .padding(6.dp) + .alpha(0.72f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = element.toString(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Clip + ) + } + HorizontalDivider() + } + } +} + + +@Composable +private fun PrimaryStacktraceCard( + modifier: Modifier = Modifier, + errorType: String, + methodFailed: String, + line: Int, + onClick: () -> Unit = {} +) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.TwoTone.BugReport, + contentDescription = stringResource(id = R.string.error), + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(4.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + modifier = Modifier, + text = errorType.uppercase(), + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), + letterSpacing = 1.sp + ), + fontWeight = FontWeight.SemiBold, + ) + Text( + text = methodFailed, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + overflow = TextOverflow.Ellipsis + + ) + Text( + text = stringResource(R.string.line, line), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis + ) + } } } } -@Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun ErrorPagePrev() { + MaterialTheme(colorScheme = darkColorScheme()) { + ErrorPage( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + throwable = Exception("An error occurred"), + onRetry = {}) + } +} + +@Preview +@Composable +private fun ErrorPagePrevWhite() { MaterialTheme { ErrorPage( - error = "An error occurred", - onRetry = {} + modifier = Modifier.background(MaterialTheme.colorScheme.background), + throwable = Exception("An error occurred"), + onRetry = {}) + } +} + +@Preview +@Composable +private fun PrimaryStacktraceCardPrev() { + MaterialTheme { + PrimaryStacktraceCard( + errorType = "Error", methodFailed = "Method failed", line = 1 + ) + } +} + +@Preview +@Composable +private fun PrimaryStacktraceCardPrevDark() { + MaterialTheme(colorScheme = darkColorScheme()) { + PrimaryStacktraceCard( + errorType = "Error", methodFailed = "Method failed", line = 1 ) } } \ No newline at end of file diff --git a/app/ui/src/main/res/values/strings.xml b/app/ui/src/main/res/values/strings.xml index ec30b16..972b8ac 100644 --- a/app/ui/src/main/res/values/strings.xml +++ b/app/ui/src/main/res/values/strings.xml @@ -11,4 +11,5 @@ Error An unknown error has occurred! Open settings + Line %1$s \ No newline at end of file