diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/MainActivity.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/MainActivity.kt index 0f88541..4e096e9 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.runtime.LaunchedEffect @@ -17,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -44,6 +46,7 @@ import io.middlepoint.tvsleep.ui.screens.Timer import io.middlepoint.tvsleep.ui.screens.timer.TimerScreen import io.middlepoint.tvsleep.ui.screens.mapToScreen import io.middlepoint.tvsleep.ui.theme.TVsleepTheme +import io.middlepoint.tvsleep.ui.theme.V2BackgroundBrush import androidx.core.content.edit @Suppress("ktlint:standard:no-consecutive-comments") @@ -64,11 +67,9 @@ class MainActivity : ComponentActivity() { modifier = Modifier .fillMaxSize() - .safeContentPadding(), - colors = - SurfaceDefaults.colors( - containerColor = MaterialTheme.colorScheme.background, - ), + .safeContentPadding() + .background(V2BackgroundBrush), + colors = SurfaceDefaults.colors(containerColor = Color.Transparent), ) { NavHost( navController = navController, diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/DashedBorder.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/DashedBorder.kt new file mode 100644 index 0000000..071c343 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/DashedBorder.kt @@ -0,0 +1,36 @@ +package io.middlepoint.tvsleep.ui.components + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.middlepoint.tvsleep.DashedBorder + +fun Modifier.dashedBorder( + color: Color = DashedBorder, + strokeWidth: Dp = 2.dp, + cornerRadius: Dp = 22.dp, + dashLength: Float = 10f, + gapLength: Float = 10f +): Modifier = this.drawBehind { + val stroke = Stroke( + width = strokeWidth.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashLength, gapLength)) + ) + drawRoundRect( + color = color, + topLeft = Offset(strokeWidth.toPx() / 2, strokeWidth.toPx() / 2), + size = Size( + size.width - strokeWidth.toPx(), + size.height - strokeWidth.toPx() + ), + cornerRadius = CornerRadius(cornerRadius.toPx()), + style = stroke + ) +} diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/TVCPBanner.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/TVCPBanner.kt new file mode 100644 index 0000000..dbf8ed8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/TVCPBanner.kt @@ -0,0 +1,101 @@ +package io.middlepoint.tvsleep.ui.components + +import androidx.compose.foundation.Image +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import io.middlepoint.tvsleep.R + +@Composable +fun TVCPBanner( + onBannerClick: () -> Unit, + modifier: Modifier = Modifier +) { + V2FocusableCard( + onClick = onBannerClick, + modifier = modifier + .fillMaxWidth() + .height(120.dp), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF1A1A2E)) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_tvcp_logo), + contentDescription = "TVCP Logo", + modifier = Modifier.size(84.dp) + ) + + Spacer(modifier = Modifier.width(24.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = "FROM THE CREATORS OF TV TIMER+", + fontSize = 12.sp, + fontWeight = FontWeight.W500, + color = Color.White.copy(alpha = 0.6f), + letterSpacing = 0.5.sp + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "TV Control Plus", + fontSize = 24.sp, + fontWeight = FontWeight.W700, + color = Color.White + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "Control your TV from your phone", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.8f) + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 20.dp, vertical = 10.dp) + ) { + Text( + text = "Get it", + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = Color.White + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2FocusableCard.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2FocusableCard.kt new file mode 100644 index 0000000..3d9b9f1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2FocusableCard.kt @@ -0,0 +1,48 @@ +package io.middlepoint.tvsleep.ui.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import io.middlepoint.tvsleep.FocusRing + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun V2FocusableCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + shape: Shape = RoundedCornerShape(22.dp), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val offsetY by animateDpAsState( + targetValue = if (isFocused) (-2).dp else 0.dp, + label = "FocusLift" + ) + + Card( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.offset(y = offsetY), + shape = CardDefaults.shape(shape), + border = CardDefaults.border( + focusedBorder = Border(BorderStroke(3.dp, FocusRing)) + ), + interactionSource = interactionSource, + content = { content() } + ) +} diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2Header.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2Header.kt new file mode 100644 index 0000000..6078d4a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2Header.kt @@ -0,0 +1,48 @@ +package io.middlepoint.tvsleep.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +@Composable +fun V2Header( + step: Int, + totalSteps: Int, + eyebrow: String, + title: String, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + V2StepDots( + currentStep = step, + totalSteps = totalSteps + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = eyebrow, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + letterSpacing = 0.1.em + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = title, + fontSize = 96.sp, + fontWeight = FontWeight.W600, + letterSpacing = (-0.035).em, + color = MaterialTheme.colorScheme.onPrimary + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2StepDots.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2StepDots.kt new file mode 100644 index 0000000..6cffb9e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/components/V2StepDots.kt @@ -0,0 +1,40 @@ +package io.middlepoint.tvsleep.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun V2StepDots( + currentStep: Int, + totalSteps: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(totalSteps) { index -> + val isActive = index < currentStep + Box( + modifier = Modifier + .width(if (isActive) 24.dp else 12.dp) + .height(6.dp) + .alpha(if (isActive) 1f else 0.4f) + .background( + color = Color.White, + shape = RoundedCornerShape(3.dp) + ) + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/home/HomeScreen.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/home/HomeScreen.kt index 1136bda..bf6257e 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/home/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/home/HomeScreen.kt @@ -1,16 +1,21 @@ package io.middlepoint.tvsleep.ui.screens.home +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +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.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid @@ -20,7 +25,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SentimentVerySatisfied -import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.outlined.Timer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,25 +33,35 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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.em +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil.compose.rememberAsyncImagePainter import io.middlepoint.tvsleep.BuildConfig +import io.middlepoint.tvsleep.DashedBorder +import io.middlepoint.tvsleep.Purple40 import io.middlepoint.tvsleep.R +import io.middlepoint.tvsleep.ui.components.TVCPBanner +import io.middlepoint.tvsleep.ui.components.V2FocusableCard +import io.middlepoint.tvsleep.ui.components.V2Header +import io.middlepoint.tvsleep.ui.components.dashedBorder import io.middlepoint.tvsleep.ui.theme.TVsleepTheme @OptIn(ExperimentalTvMaterial3Api::class) @@ -70,22 +84,9 @@ fun HomeScreen( Column( modifier = modifier .fillMaxSize() - .padding(20.dp), + .padding(top = 72.dp, start = 96.dp, end = 96.dp, bottom = 64.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - val title = when (uiState.selectionMode) { - SelectionMode.Time -> stringResource(R.string.timer_setup_title) - SelectionMode.App -> stringResource(R.string.app_selection_title) - } - - Text( - text = title, - modifier = Modifier.padding(top = 40.dp), - style = MaterialTheme.typography.displayLarge, - ) - - Spacer(modifier = Modifier.size(20.dp)) - AnimatedContent(targetState = uiState.selectionMode, label = "Time/App selection") { when (it) { SelectionMode.Time -> @@ -136,15 +137,21 @@ private fun AllApps( onEvent: (TimeSelectionEvent) -> Unit ) { LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(4), - contentPadding = PaddingValues(16.dp), + columns = StaggeredGridCells.Fixed(6), + contentPadding = PaddingValues(0.dp), modifier = Modifier.focusRequester(focusRequester), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalItemSpacing = 18.dp, + horizontalArrangement = Arrangement.spacedBy(18.dp), userScrollEnabled = true, ) { items(state.installedApps) { app -> - AppCard(app = app, onEvent = onEvent) + AppCard( + app = app, + onEvent = onEvent, + height = 184.dp, + iconSize = 60.dp, + labelSize = 22 + ) } } } @@ -156,34 +163,60 @@ private fun CuratedApps( state: TimeSelectionState, onEvent: (TimeSelectionEvent) -> Unit ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(4), - contentPadding = PaddingValues(16.dp), - modifier = Modifier.focusRequester(focusRequester), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - userScrollEnabled = true, + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { + V2Header( + step = 2, + totalSteps = 2, + eyebrow = "STEP 2 OF 2 · PICK AN APP", + title = stringResource(R.string.app_selection_title) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(4), + contentPadding = PaddingValues(0.dp), + modifier = Modifier.focusRequester(focusRequester), + verticalItemSpacing = 22.dp, + horizontalArrangement = Arrangement.spacedBy(22.dp), + userScrollEnabled = true, + ) { item { - Card( + V2FocusableCard( onClick = { onEvent(TimeSelectionEvent.StartTimerOnly) }, - modifier = Modifier.size(100.dp), + modifier = Modifier.height(232.dp), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Icon( - imageVector = Icons.Outlined.Timer, - contentDescription = stringResource(R.string.start_timer_only), - modifier = Modifier.size(48.dp) - ) + Box( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(18.dp)) + .background(Purple40), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = stringResource(R.string.start_timer_only), + modifier = Modifier.size(48.dp), + tint = Color.White + ) + } + Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.start_timer_only), textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, + fontSize = 28.sp, + fontWeight = FontWeight.W600, + maxLines = 2, overflow = TextOverflow.Ellipsis ) } @@ -195,9 +228,11 @@ private fun CuratedApps( AppCard(app = app, onEvent = onEvent) } item { - Card( + V2FocusableCard( onClick = { onEvent(TimeSelectionEvent.OnAddAppsClicked) }, - modifier = Modifier.size(100.dp), + modifier = Modifier + .height(232.dp) + .dashedBorder(), ) { Column( modifier = Modifier.fillMaxSize(), @@ -207,42 +242,59 @@ private fun CuratedApps( Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add_apps), - modifier = Modifier.size(48.dp) + modifier = Modifier.size(48.dp), + tint = DashedBorder ) + Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.add_apps), textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, + fontSize = 28.sp, + fontWeight = FontWeight.W600, + color = DashedBorder, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } - } + } // end LazyVerticalStaggeredGrid + } // end Column } @Composable @OptIn(ExperimentalTvMaterial3Api::class) -private fun AppCard(app: AppInfo, onEvent: (TimeSelectionEvent) -> Unit) { - Card( +private fun AppCard( + app: AppInfo, + onEvent: (TimeSelectionEvent) -> Unit, + height: Dp = 232.dp, + iconSize: Dp = 80.dp, + labelSize: Int = 28 +) { + V2FocusableCard( onClick = { onEvent(TimeSelectionEvent.OnAppSelected(app)) }, - modifier = Modifier.size(100.dp), + modifier = Modifier.height(height), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Image( painter = rememberAsyncImagePainter(app.icon), contentDescription = app.label, - modifier = Modifier.size(48.dp) + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(18.dp)) ) + Spacer(modifier = Modifier.height(12.dp)) Text( text = app.label, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, + fontSize = labelSize.sp, + fontWeight = FontWeight.W600, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -264,14 +316,43 @@ private fun TimerSetup( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(4), - contentPadding = PaddingValues(16.dp), - modifier = Modifier.focusRequester(focusRequester), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - userScrollEnabled = true, + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { + V2Header( + step = 1, + totalSteps = 2, + eyebrow = "STEP 1 OF 2 · PICK A DURATION", + title = stringResource(R.string.timer_setup_title) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + val context = LocalContext.current + TVCPBanner( + onBannerClick = { + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=io.middlepoint.tvcp")) + ) + } catch (e: ActivityNotFoundException) { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=io.middlepoint.tvcp")) + ) + } + }, + modifier = Modifier.padding(bottom = 24.dp) + ) + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(4), + contentPadding = PaddingValues(0.dp), + modifier = Modifier.focusRequester(focusRequester), + verticalItemSpacing = 22.dp, + horizontalArrangement = Arrangement.spacedBy(22.dp), + userScrollEnabled = true, + ) { if (BuildConfig.DEBUG) { item { TimeOption( @@ -306,12 +387,14 @@ private fun TimerSetup( time = "Custom", isInDeleteMode = false, isEasterEgg = state.showEasterEgg, + isCustomTile = true, onClick = onNavigateToCustomTime, onLongClick = { onEvent(TimeSelectionEvent.ShowEasterEgg) }, ) } } - } + } // end Column + } // end Box LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -330,6 +413,7 @@ private fun TimeOption( time: String = "00:00", isInDeleteMode: Boolean, isEasterEgg: Boolean, + isCustomTile: Boolean = false, onClick: () -> Unit, onLongClick: (() -> Unit)?, ) { @@ -338,15 +422,24 @@ private fun TimeOption( label = "Card color", ) - Card( + val baseModifier = Modifier.height(232.dp) + val tileModifier = if (isCustomTile) { + baseModifier + .dashedBorder() + .background(Color.Transparent) + } else { + baseModifier + } + + V2FocusableCard( onClick = onClick, onLongClick = onLongClick, - modifier = Modifier.size(100.dp), - shape = CardDefaults.shape(), - colors = CardDefaults.colors(containerColor = animatedColor), + modifier = tileModifier, ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(if (isCustomTile) Color.Transparent else animatedColor), contentAlignment = Alignment.Center, ) { val contentState = @@ -360,9 +453,9 @@ private fun TimeOption( TimeOptionContentState.Normal -> { Text( text = time, - modifier = Modifier, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, + fontSize = 56.sp, + fontWeight = FontWeight.W700, + letterSpacing = (-0.025).em, textAlign = TextAlign.Center, ) } diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/timer/TimerScreen.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/timer/TimerScreen.kt index 3dc41f8..5f4543e 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/timer/TimerScreen.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/screens/timer/TimerScreen.kt @@ -3,14 +3,17 @@ package io.middlepoint.tvsleep.ui.screens.timer import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.animateFloatAsState +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.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +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,15 +26,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme @@ -39,6 +42,8 @@ import androidx.tv.material3.Text import io.middlepoint.tvsleep.R import io.middlepoint.tvsleep.TimerState import io.middlepoint.tvsleep.ui.components.MainTimer +import io.middlepoint.tvsleep.ui.components.V2FocusableCard +import io.middlepoint.tvsleep.ui.theme.V2BackgroundBrush @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -46,75 +51,51 @@ fun TimerScreen( modifier: Modifier = Modifier, viewModel: TimerScreenViewModel = viewModel(), ) { - val timerLabel by viewModel.timerLabel.collectAsState() // HH:MM:SS - val selectedTimeOptionLabel by viewModel.selectedTimeOptionLabel.collectAsState() // Custom Label + val timerLabel by viewModel.timerLabel.collectAsState() + val selectedTimeOptionLabel by viewModel.selectedTimeOptionLabel.collectAsState() val timerScreenState by viewModel.timerScreenState.collectAsState() val timerProgressOffset by viewModel.timerProgressOffset.collectAsState() - ConstraintLayout( - modifier = modifier.fillMaxSize(), - ) { - val (label, timerContent, actionButtons) = createRefs() - val animatedProgress by animateFloatAsState( - targetValue = timerProgressOffset, - animationSpec = - SpringSpec( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, // Changed from StiffnessVeryLow - visibilityThreshold = 1 / 1000f, - ), - label = "TimerProgressAnimation", - ) + val animatedProgress by animateFloatAsState( + targetValue = timerProgressOffset, + animationSpec = SpringSpec( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + visibilityThreshold = 1 / 1000f, + ), + label = "TimerProgressAnimation", + ) + Column( + modifier = modifier + .fillMaxSize() + .background(V2BackgroundBrush), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { Text( text = selectedTimeOptionLabel, - style = MaterialTheme.typography.headlineMedium, - modifier = - Modifier.constrainAs(label) { - linkTo( - start = parent.start, - top = parent.top, - end = parent.end, - bottom = timerContent.top, - bottomMargin = 16.dp, - topMargin = 16.dp, - verticalBias = 0.67f, // Center vertically in the available space - ) - }, + fontSize = 36.sp, + fontWeight = FontWeight.W500, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.alpha(0.85f) ) + Spacer(modifier = Modifier.height(32.dp)) + MainTimer( animatedProgress = animatedProgress, formattedTime = timerLabel, timerScreenState = timerScreenState, - modifier = - Modifier.size(247.dp).constrainAs(timerContent) { - linkTo( - start = parent.start, - top = parent.top, - end = parent.end, - bottom = parent.bottom, // Changed from actionButtons.top to parent.bottom - bottomMargin = 16.dp, - topMargin = 16.dp, - verticalBias = 0.5f, // Center vertically in the available space - ) - }, + modifier = Modifier.size(520.dp) ) + Spacer(modifier = Modifier.height(48.dp)) + ActionButtons( onActionClick = { viewModel.onActionClick() }, onDelete = { viewModel.onDelete() }, timerScreenState = timerScreenState, - modifier = - Modifier - .constrainAs(actionButtons) { - bottom.linkTo(parent.bottom, margin = 32.dp) - linkTo( - start = parent.start, - end = parent.end, - ) - width = Dimension.fillToConstraints - }, ) } } @@ -134,53 +115,53 @@ private fun ActionButtons( } Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - Card( + V2FocusableCard( onClick = onActionClick, - modifier = - Modifier - .size(96.dp) - .focusRequester(focusRequester), - shape = CardDefaults.shape(), + modifier = Modifier + .size(124.dp) + .focusRequester(focusRequester), + shape = RoundedCornerShape(28.dp), ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { - val icon = - when (timerScreenState) { - is TimerState.Running, TimerState.Started -> Icons.Filled.Pause - is TimerState.Start, TimerState.Paused, TimerState.Stopped -> Icons.Filled.PlayArrow - TimerState.Finish, TimerState.Finished -> Icons.Filled.Stop - } + val icon = when (timerScreenState) { + is TimerState.Running, TimerState.Started -> Icons.Filled.Pause + is TimerState.Start, TimerState.Paused, TimerState.Stopped -> Icons.Filled.PlayArrow + TimerState.Finish, TimerState.Finished -> Icons.Filled.Stop + } Icon( imageVector = icon, contentDescription = stringResource(R.string.label_start), - modifier = Modifier.size(48.dp), + modifier = Modifier.size(56.dp), ) } } Spacer(Modifier.width(80.dp)) - Card( + V2FocusableCard( onClick = onDelete, - modifier = - Modifier - .size(96.dp), - shape = CardDefaults.shape(), + modifier = Modifier.size(124.dp), + shape = RoundedCornerShape(28.dp), ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Delete, contentDescription = stringResource(R.string.label_delete), - modifier = Modifier.size(48.dp), + modifier = Modifier.size(56.dp), ) } } diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/theme/Theme.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/theme/Theme.kt index 06a4105..ec4d027 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/theme/Theme.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/tvsleep/ui/theme/Theme.kt @@ -2,11 +2,25 @@ package io.middlepoint.tvsleep.ui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.tv.material3.MaterialTheme import androidx.tv.material3.lightColorScheme +import io.middlepoint.tvsleep.BgGradientBottom +import io.middlepoint.tvsleep.BgGradientTop import io.middlepoint.tvsleep.Purple40 +val V2BackgroundBrush: Brush + @Composable + get() = remember { + Brush.radialGradient( + colors = listOf(BgGradientTop, Purple40, BgGradientBottom), + center = Offset.Zero, + radius = 2000f + ) + } + @Composable fun TVsleepTheme(content: @Composable () -> Unit) { val colorScheme = diff --git a/composeApp/src/androidMain/res/drawable/ic_tvcp_logo.xml b/composeApp/src/androidMain/res/drawable/ic_tvcp_logo.xml new file mode 100644 index 0000000..c8ebc02 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_tvcp_logo.xml @@ -0,0 +1,12 @@ + + + + diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/tvsleep/Color.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/tvsleep/Color.kt index 148c05b..9dbbb9a 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/tvsleep/Color.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/tvsleep/Color.kt @@ -13,3 +13,8 @@ val Pink40 = Color(0xFF7D5260) val White = Color(0xFFFFFFFF) val ButtonWhite = Color(0xFFF0F0F0) // A slightly off-white for buttons val Black = Color(0xFF000000) + +val FocusRing = Color(0xFFFFD8E4) +val BgGradientTop = Color(0xFF7B5FAF) // oklch(0.42 0.16 295) converted +val BgGradientBottom = Color(0xFF4A3B7A) // oklch(0.30 0.14 295) converted +val DashedBorder = Color(0x52FFFFFF) // rgba(255,255,255,0.32)