diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt index 4703e43..205d3fb 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt @@ -12,112 +12,87 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.navigation.ItemDto -import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun EpisodeCard( - item: ItemDto, + episode: EpisodeUiModel, modifier: Modifier = Modifier, - viewModel: EpisodeScreenViewModel = hiltViewModel() ) { - LaunchedEffect(item) { - when (item.type) { - BaseItemKind.EPISODE -> viewModel.selectEpisode(item.id) - else -> return@LaunchedEffect - } - } + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(EpisodeBackgroundDark) + ) { + val isWide = maxWidth >= 900.dp + val contentPadding = if (isWide) 32.dp else 20.dp - val episodeItem = viewModel.episode.collectAsState() - - if (episodeItem.value != null) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(EpisodeBackgroundDark) - ) { - val isWide = maxWidth >= 900.dp - val contentPadding = if (isWide) 32.dp else 20.dp - - Box(modifier = Modifier.fillMaxSize()) { - if (isWide) { - Row(modifier = Modifier.fillMaxSize()) { - EpisodeHero( - episode = episodeItem.value!!, - height = 300.dp, - isWide = true, - modifier = Modifier - .fillMaxHeight() - .weight(0.5f) - ) - EpisodeDetails( - episode = episodeItem.value!!, - modifier = Modifier - .weight(0.5f) - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - .padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp) - ) - } - } else { - Column( + Box(modifier = Modifier.fillMaxSize()) { + if (isWide) { + Row(modifier = Modifier.fillMaxSize()) { + EpisodeHero( + episode = episode, + height = 300.dp, + isWide = true, modifier = Modifier - .fillMaxSize() + .fillMaxHeight() + .weight(0.5f) + ) + EpisodeDetails( + episode = episode, + modifier = Modifier + .weight(0.5f) + .fillMaxHeight() .verticalScroll(rememberScrollState()) - ) { - EpisodeHero( - episode = episodeItem.value!!, - height = 400.dp, - isWide = false, - modifier = Modifier.fillMaxWidth() - ) - EpisodeDetails( - episode = episodeItem.value!!, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentPadding) - .offset(y = (-48).dp) - .padding(bottom = 96.dp) - ) - } + .padding( + start = contentPadding, + end = contentPadding, + top = 96.dp, + bottom = 32.dp + ) + ) } - - EpisodeTopBar( + } else { + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - ) - - if (!isWide) { - FloatingPlayButton( + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + EpisodeHero( + episode = episode, + height = 400.dp, + isWide = false, + modifier = Modifier.fillMaxWidth() + ) + EpisodeDetails( + episode = episode, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(20.dp) + .fillMaxWidth() + .padding(horizontal = contentPadding) + .offset(y = (-48).dp) + .padding(bottom = 96.dp) ) } } - } - } else { - Box( - modifier = modifier - .fillMaxSize() - .background(EpisodeBackgroundDark), - contentAlignment = Alignment.Center - ) { - Text( - text = "Loading...", - color = Color.White + + EpisodeTopBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) ) + + if (!isWide) { + FloatingPlayButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp) + ) + } } } + } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index a96fbda..4708703 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -59,13 +59,20 @@ import coil3.compose.AsyncImage import hu.bbara.purefin.player.PlayerActivity @Composable -internal fun EpisodeTopBar(modifier: Modifier = Modifier) { +internal fun EpisodeTopBar( + viewModel: EpisodeScreenViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back") + GhostIconButton( + onClick = { viewModel.onBack() }, + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back" + ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") @@ -75,6 +82,7 @@ internal fun EpisodeTopBar(modifier: Modifier = Modifier) { @Composable private fun GhostIconButton( + onClick: () -> Unit = {}, icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier @@ -84,7 +92,7 @@ private fun GhostIconButton( .size(40.dp) .clip(CircleShape) .background(EpisodeBackgroundDark.copy(alpha = 0.4f)) - .clickable { }, + .clickable { onClick() }, contentAlignment = Alignment.Center ) { Icon( diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 41b624c..ca27be3 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -1,16 +1,32 @@ package hu.bbara.purefin.app.content.episode import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.navigation.ItemDto @Composable fun EpisodeScreen( episode: ItemDto, + viewModel: EpisodeScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { - EpisodeCard( - item = episode, - modifier = modifier - ) + + LaunchedEffect(episode) { + viewModel.selectEpisode(episode.id) + } + + val episode = viewModel.episode.collectAsState() + + if (episode.value != null) { + EpisodeCard( + episode = episode.value!!, + modifier = modifier + ) + } else { + PurefinWaitingScreen() + } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt index 9f6aac6..58b71d1 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.navigation.ItemDto +import hu.bbara.purefin.navigation.NavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import java.time.LocalDateTime @@ -23,12 +27,28 @@ import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, + private val navigationManager: NavigationManager, private val userSessionRepository: UserSessionRepository ): ViewModel() { private val _episode = MutableStateFlow(null) val episode = _episode.asStateFlow() + fun onSeriesSelected(seriesId: String) { + viewModelScope.launch { + navigationManager.navigate(Route.Series(ItemDto(UUID.fromString(seriesId), BaseItemKind.SERIES))) + } + } + + fun onBack() { + navigationManager.pop() + } + + + fun onGoHome() { + navigationManager.replaceAll(Route.Home) + } + fun selectNextUpEpisodeForSeries(seriesId: UUID) { viewModelScope.launch { val episode = jellyfinApiClient.getNextUpEpisode(seriesId) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt index 098713b..686e667 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt @@ -12,108 +12,87 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.navigation.ItemDto @Composable fun MovieCard( - movie: ItemDto, + movie: MovieUiModel, modifier: Modifier = Modifier, - viewModel: MovieScreenViewModel = hiltViewModel() ) { - LaunchedEffect(movie.id) { - viewModel.selectMovie(movie.id) - } + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(MovieBackgroundDark) + ) { + val isWide = maxWidth >= 900.dp + val contentPadding = if (isWide) 32.dp else 20.dp - val movieItem = viewModel.movie.collectAsState() - - if (movieItem.value != null) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(MovieBackgroundDark) - ) { - val isWide = maxWidth >= 900.dp - val contentPadding = if (isWide) 32.dp else 20.dp - - Box(modifier = Modifier.fillMaxSize()) { - if (isWide) { - Row(modifier = Modifier.fillMaxSize()) { - MovieHero( - movie = movieItem.value!!, - height = 300.dp, - isWide = true, - modifier = Modifier - .fillMaxHeight() - .weight(0.5f) - ) - MovieDetails( - movie = movieItem.value!!, - modifier = Modifier - .weight(0.5f) - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - .padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp) - ) - } - } else { - Column( + Box(modifier = Modifier.fillMaxSize()) { + if (isWide) { + Row(modifier = Modifier.fillMaxSize()) { + MovieHero( + movie = movie, + height = 300.dp, + isWide = true, modifier = Modifier - .fillMaxSize() + .fillMaxHeight() + .weight(0.5f) + ) + MovieDetails( + movie = movie, + modifier = Modifier + .weight(0.5f) + .fillMaxHeight() .verticalScroll(rememberScrollState()) - ) { - MovieHero( - movie = movieItem.value!!, - height = 400.dp, - isWide = false, - modifier = Modifier.fillMaxWidth() - ) - MovieDetails( - movie = movieItem.value!!, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentPadding) - .offset(y = (-48).dp) - .padding(bottom = 96.dp) - ) - } + .padding( + start = contentPadding, + end = contentPadding, + top = 96.dp, + bottom = 32.dp + ) + ) } - - MovieTopBar( + } else { + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - ) - - if (!isWide) { - FloatingPlayButton( + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MovieHero( + movie = movie, + height = 400.dp, + isWide = false, + modifier = Modifier.fillMaxWidth() + ) + MovieDetails( + movie = movie, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(20.dp) + .fillMaxWidth() + .padding(horizontal = contentPadding) + .offset(y = (-48).dp) + .padding(bottom = 96.dp) ) } } - } - } else { - Box( - modifier = modifier - .fillMaxSize() - .background(MovieBackgroundDark), - contentAlignment = Alignment.Center - ) { - Text( - text = "Loading...", - color = Color.White + + MovieTopBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) ) + + if (!isWide) { + FloatingPlayButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp) + ) + } } } + } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index 9786055..a39d1cf 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -59,13 +59,20 @@ import coil3.compose.AsyncImage import hu.bbara.purefin.player.PlayerActivity @Composable -internal fun MovieTopBar(modifier: Modifier = Modifier) { +internal fun MovieTopBar( + viewModel: MovieScreenViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back") + GhostIconButton( + onClick = { viewModel.onBack() }, + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back" + ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") @@ -77,6 +84,7 @@ internal fun MovieTopBar(modifier: Modifier = Modifier) { private fun GhostIconButton( icon: ImageVector, contentDescription: String, + onClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Box( @@ -84,7 +92,7 @@ private fun GhostIconButton( .size(40.dp) .clip(CircleShape) .background(MovieBackgroundDark.copy(alpha = 0.4f)) - .clickable { }, + .clickable { onClick() }, contentAlignment = Alignment.Center ) { Icon( @@ -133,8 +141,7 @@ internal fun MovieHero( .background( Brush.horizontalGradient( colors = listOf( - Color.Transparent, - MovieBackgroundDark.copy(alpha = 0.8f) + Color.Transparent, MovieBackgroundDark.copy(alpha = 0.8f) ) ) ) @@ -431,7 +438,7 @@ private fun PlayButton( .shadow(24.dp, CircleShape) .clip(CircleShape) .background(MoviePrimary) - .clickable{ + .clickable { val intent = Intent(context, PlayerActivity::class.java) intent.putExtra("MEDIA_ID", movieId.value!!.id.toString()) context.startActivity(intent) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index 394cc6f..8553e83 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -1,16 +1,31 @@ package hu.bbara.purefin.app.content.movie import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.navigation.ItemDto @Composable fun MovieScreen( movie: ItemDto, + viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { - MovieCard( - movie = movie, - modifier = modifier - ) + LaunchedEffect(movie.id) { + viewModel.selectMovie(movie.id) + } + + val movieItem = viewModel.movie.collectAsState() + + if (movieItem.value != null) { + MovieCard( + movie = movieItem.value!!, + modifier = modifier + ) + } else { + PurefinWaitingScreen() + } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt index 4c73261..a55459e 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.navigation.NavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,12 +22,22 @@ import javax.inject.Inject @HiltViewModel class MovieScreenViewModel @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, + private val navigationManager: NavigationManager, private val userSessionRepository: UserSessionRepository ): ViewModel() { private val _movie = MutableStateFlow(null) val movie = _movie.asStateFlow() + fun onBack() { + navigationManager.pop() + } + + + fun onGoHome() { + navigationManager.replaceAll(Route.Home) + } + fun selectMovie(movieId: UUID) { viewModelScope.launch { val movieInfo = jellyfinApiClient.getItemInfo(movieId) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt index 6b157b9..390474a 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesCard.kt @@ -1,7 +1,6 @@ package hu.bbara.purefin.app.content.series import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -14,61 +13,54 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.navigation.ItemDto @Composable fun SeriesCard( - series: ItemDto, + series: SeriesUiModel, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel() ) { - LaunchedEffect(series.id) { - viewModel.selectSeries(series.id) - } - val series = viewModel.series.collectAsState() - - if (series.value != null) { - BoxWithConstraints( - modifier = modifier + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(SeriesBackgroundDark) + ) { + val heroHeight = maxHeight * 0.6f + Column( + modifier = Modifier .fillMaxSize() - .background(SeriesBackgroundDark) + .verticalScroll(rememberScrollState()) ) { - val heroHeight = maxHeight * 0.6f + SeriesHero( + imageUrl = series.heroImageUrl, + height = heroHeight + ) Column( modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .offset(y = (-96).dp) ) { - SeriesHero( - imageUrl = series.value!!.heroImageUrl, - height = heroHeight - ) Column( modifier = Modifier .fillMaxWidth() - .offset(y = (-96).dp) .padding(horizontal = 20.dp) - .padding(bottom = 32.dp) ) { Text( - text = series.value!!.title, + text = series.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold, lineHeight = 36.sp ) Spacer(modifier = Modifier.height(16.dp)) - SeriesMetaChips(series = series.value!!) + SeriesMetaChips(series = series) Spacer(modifier = Modifier.height(24.dp)) SeriesActionButtons() Spacer(modifier = Modifier.height(24.dp)) @@ -80,20 +72,32 @@ fun SeriesCard( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = series.value!!.synopsis, + text = series.synopsis, color = SeriesMutedStrong, fontSize = 13.sp, - lineHeight = 20.sp ) Spacer(modifier = Modifier.height(28.dp)) - SeasonTabs(seasons = series.value!!.seasonTabs) + Text( + text = "Episodes", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(28.dp)) + SeasonTabs(seasons = series.seasonTabs) Spacer(modifier = Modifier.height(16.dp)) } - EpisodeCarousel(episodes = series.value!!.seasonTabs.firstOrNull { it.isSelected }?.episodes.orEmpty()) + + EpisodeCarousel( + episodes = series.seasonTabs.firstOrNull { it.isSelected }?.episodes + ?: series.seasonTabs.firstOrNull()?.episodes + ?: emptyList() + ) + Spacer(modifier = Modifier.height(32.dp)) Column( modifier = Modifier .fillMaxWidth() - .padding(top = 24.dp, bottom = 32.dp) + .padding(top = 0.dp, bottom = 0.dp) ) { Text( text = "Cast", @@ -103,28 +107,22 @@ fun SeriesCard( modifier = Modifier.padding(horizontal = 20.dp) ) Spacer(modifier = Modifier.height(12.dp)) - CastRow(cast = series.value!!.cast) + CastRow(cast = series.cast) } } + } - SeriesTopBar( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - .align(Alignment.TopCenter) - ) - } - } else { - Box( - modifier = modifier - .fillMaxSize() - .background(SeriesBackgroundDark), - contentAlignment = Alignment.Center - ) { - Text( - text = "Loading...", - color = Color.White - ) - } + SeriesTopBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + .align(Alignment.TopCenter) + ) } } + +@Preview +@Composable +fun SeriesCardPreview() { + SeriesCard(series = SeriesMockData.series()) +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index b2ef5ef..270acb5 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -50,16 +50,23 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import coil3.compose.AsyncImage @Composable -internal fun SeriesTopBar(modifier: Modifier = Modifier) { +internal fun SeriesTopBar( + viewModel: SeriesViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back") + GhostIconButton( + onClick = { viewModel.onBack() }, + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back") Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") @@ -69,6 +76,7 @@ internal fun SeriesTopBar(modifier: Modifier = Modifier) { @Composable private fun GhostIconButton( + onClick: () -> Unit = {}, icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier @@ -78,7 +86,7 @@ private fun GhostIconButton( .size(40.dp) .clip(CircleShape) .background(SeriesBackgroundDark.copy(alpha = 0.4f)) - .clickable { }, + .clickable { onClick() }, contentAlignment = Alignment.Center ) { Icon( @@ -267,7 +275,10 @@ internal fun EpisodeCarousel(episodes: List, modifier: Mod } @Composable -private fun EpisodeCard(episode: SeriesEpisodeUiModel) { +private fun EpisodeCard( + viewModel: SeriesViewModel = hiltViewModel(), + episode: SeriesEpisodeUiModel +) { Column( modifier = Modifier .width(260.dp) @@ -275,7 +286,7 @@ private fun EpisodeCard(episode: SeriesEpisodeUiModel) { .background(SeriesSurfaceDark.copy(alpha = 0.3f)) .border(1.dp, SeriesSurfaceBorder, RoundedCornerShape(16.dp)) .padding(12.dp) - .clickable { }, + .clickable { viewModel.onSelectEpisode(episode.id) }, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Box( diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt index ba59196..10293eb 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesModels.kt @@ -1,6 +1,7 @@ package hu.bbara.purefin.app.content.series data class SeriesEpisodeUiModel( + val id: String, val title: String, val description: String, val duration: String, @@ -35,18 +36,21 @@ internal object SeriesMockData { fun series(): SeriesUiModel { val heroUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuD3hBjDpw00tDCQsK5xNcnJra301k1T4LksWVZzHieH9KHQItEQkVzhwevJvf8RkaQKdVKvObzRlfDDqa3_PNwLUlUQc1LpDih8p94VTGobEV62qi7QrmNyQm_o55KRMNWiTG3zLLpblGqo3uUNQcYmPFqfNML95dClXQ4lQNl85-zgerPPAbGPr23dswbIYCigyTAaXgrmdV_nbNQ5LdDB0Wh5cMHtP0uxz6k3ARjNom6clhphGIUF9e6YSvKuwuiZ-1lMYFg8C_4" val episode1 = SeriesEpisodeUiModel( + id = "1", title = "E1: The Beginning", description = "The crew assembles for the first time as the anomaly begins to expand rapidly near Saturn's rings.", duration = "58m", imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuC6OPszCXCIP_FMO3BJJUrjpCtDNw9aeHYOGyOAXdqF078hDFNrH7KXbaQ7qtipz6aIPLivd8VBBffNMbeAiYIjjWjn5GMb6Xn9iiJz0D2rzhCKi0TBeFrN6tC1IXJkzQyQKJNhTnyokWy9dd-YtN65V7er7RT6hP5jdVBXhtK1xZMjlgrm1bk_FTTmKd8Afu3zPtJCaaC98Z608vav5zhYlkrdA1wKNSTWTpzwMSyDIY3pNQNPFauWf0n-iEu7QsYTAwhCG_zfxz0" ) val episode2 = SeriesEpisodeUiModel( + id = "2", title = "E2: Event Horizon", description = "Dr. Cole discovers a frequency embedded in the rift's radiation that suggests intelligent design.", duration = "54m", imageUrl = "https://lh3.googleusercontent.com/aida-public/AB6AXuBExsf-wEzAVjMxasU2ImGhlreqQo9biBSN1yHyAbW8MyuhuppRw9ho7OD3vsbySSJ3kNluEgH1Qun45PmLnZWixZsFU4Qc7UGGJNKMS5Nkm4GZAsKdFvb3z_i1tkCvaXXvGpqmwI0qjFuo1QyjjhYPA5Yp3I8ZhrnDYdQv_GxbhR6Vl3mY1rbxd2BIUEE5oMTwTF-QmJztUEaViZkSGSG2VgVXZ5VAREn4xWE902OH2sysllvXQJQIaj439JIC2_Vg61m0-F-F1Vc" ) val episode3 = SeriesEpisodeUiModel( + id = "3", title = "E3: Singularity", description = "Tension rises as the ship approaches the event horizon, and the AI begins to behave erratically.", duration = "1h 02m", diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index dc9b2db..45f15b3 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -1,16 +1,31 @@ package hu.bbara.purefin.app.content.series import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.navigation.ItemDto @Composable fun SeriesScreen( series: ItemDto, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: SeriesViewModel = hiltViewModel() ) { - SeriesCard( - series = series, - modifier = modifier - ) + LaunchedEffect(series.id) { + viewModel.selectSeries(series.id) + } + + val series = viewModel.series.collectAsState() + + if (series.value != null) { + SeriesCard( + series = series.value!!, + modifier = modifier + ) + } else { + PurefinWaitingScreen() + } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt index 4458fb9..5f4ba04 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.navigation.ItemDto +import hu.bbara.purefin.navigation.NavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import javax.inject.Inject @@ -19,12 +23,28 @@ import javax.inject.Inject @HiltViewModel class SeriesViewModel @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, + private val navigationManager: NavigationManager, private val userSessionRepository: UserSessionRepository ) : ViewModel() { private val _series = MutableStateFlow(null) val series = _series.asStateFlow() + fun onSelectEpisode(episodeId: String) { + viewModelScope.launch { + navigationManager.navigate(Route.Episode(ItemDto(UUID.fromString(episodeId), BaseItemKind.EPISODE))) + } + } + + fun onBack() { + navigationManager.pop() + } + + + fun onGoHome() { + navigationManager.replaceAll(Route.Home) + } + fun selectSeries(seriesId: UUID) { viewModelScope.launch { val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank { @@ -35,7 +55,13 @@ class SeriesViewModel @Inject constructor( val episodesItemResult = seasonsItemResult.associate { season -> season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id) } - _series.value = mapToSeriesUiModel(serverUrl, seriesItemResult, seasonsItemResult, episodesItemResult) + val seriesUiModel = mapToSeriesUiModel( + serverUrl, + seriesItemResult, + seasonsItemResult, + episodesItemResult + ) + _series.value = seriesUiModel } } @@ -49,25 +75,20 @@ class SeriesViewModel @Inject constructor( val episodeItemResult = episodesItemResult[season.id] ?: emptyList() val episodeItemUiModels = episodeItemResult.map { episode -> SeriesEpisodeUiModel( + id = episode.id.toString(), title = episode.name ?: "Unknown", description = episode.overview ?: "", duration = "58m", - imageUrl = "" + imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY) ) } SeriesSeasonUiModel( name = season.name ?: "Unknown", episodes = episodeItemUiModels, + // TODO add actual logic or remove isSelected = false, ) } - val heroImageUrl = seriesItemResult?.let { series -> - JellyfinImageHelper.toImageUrl( - url = serverUrl, - itemId = series.id, - type = ImageType.BACKDROP - ) - } ?: "" return SeriesUiModel( title = seriesItemResult?.name ?: "Unknown", format = seriesItemResult?.container ?: "VIDEO", @@ -75,7 +96,11 @@ class SeriesViewModel @Inject constructor( year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(), seasons = "3 Seasons", synopsis = seriesItemResult.overview ?: "No synopsis available.", - heroImageUrl = "", + heroImageUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = seriesItemResult.id, + type = ImageType.BACKDROP + ), seasonTabs = seasonUiModels, cast = seriesItemResult.people.orEmpty().map { it.toCastMember() } ) diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt b/app/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt new file mode 100644 index 0000000..46b9dc4 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt @@ -0,0 +1,197 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PurefinWaitingScreen( + modifier: Modifier = Modifier +) { + val accentColor = Color(0xFFBD542E) + val backgroundColor = Color(0xFF141517) + val surfaceColor = Color(0xFF1E2124) + val textPrimary = Color.White + val textSecondary = Color(0xFF9EA3A8) + + val transition = rememberInfiniteTransition(label = "waiting-pulse") + val pulseScale = transition.animateFloat( + initialValue = 0.9f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse-scale" + ) + val pulseAlpha = transition.animateFloat( + initialValue = 0.2f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse-alpha" + ) + + val gradient = Brush.radialGradient( + colors = listOf( + accentColor.copy(alpha = 0.28f), + backgroundColor + ) + ) + + Box( + modifier = modifier + .fillMaxSize() + .background(gradient) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .background(surfaceColor.copy(alpha = 0.92f)) + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(86.dp) + .graphicsLayer { + scaleX = pulseScale.value + scaleY = pulseScale.value + } + .alpha(pulseAlpha.value) + .border( + width = 2.dp, + color = accentColor.copy(alpha = 0.6f), + shape = RoundedCornerShape(26.dp) + ) + ) + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(22.dp)) + .background(accentColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Movie, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp) + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Connecting", + color = textPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "Summoning the media gnomes...", + color = textSecondary, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(24.dp)) + + WaitingDots(accentColor = accentColor) + } + } +} + +@Composable +private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "waiting-dots") + val firstAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-1" + ) + val secondAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-2" + ) + val thirdAlpha = transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot-3" + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + WaitingDot(alpha = firstAlpha.value, color = accentColor) + WaitingDot(alpha = secondAlpha.value, color = accentColor) + WaitingDot(alpha = thirdAlpha.value, color = accentColor) + } +} + +@Composable +private fun WaitingDot(alpha: Float, color: Color) { + Box( + modifier = Modifier + .size(10.dp) + .graphicsLayer { + val scale = 0.7f + (alpha * 0.3f) + scaleX = scale + scaleY = scale + } + .alpha(alpha) + .background(color, CircleShape) + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt b/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt index f48daa8..902123e 100644 --- a/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt @@ -26,7 +26,10 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,6 +40,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.PurefinComplexTextField import hu.bbara.purefin.common.ui.PurefinPasswordField import hu.bbara.purefin.common.ui.PurefinTextButton +import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.login.viewmodel.LoginViewModel import kotlinx.coroutines.launch @@ -47,132 +51,140 @@ fun LoginScreen( ) { val JellyfinOrange = Color(0xFFBD542E) val JellyfinBg = Color(0xFF141517) - val JellyfinSurface = Color(0xFF1E2124) val TextSecondary = Color(0xFF9EA3A8) // Observe ViewModel state val serverUrl by viewModel.url.collectAsState() val username by viewModel.username.collectAsState() val password by viewModel.password.collectAsState() + var isLoggingIn by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() - Column( - modifier = modifier - .fillMaxSize() - .background(JellyfinBg) - .padding(24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.weight(0.5f)) - - // Logo Section - Box( - modifier = Modifier - .size(100.dp) - .background(JellyfinOrange, RoundedCornerShape(24.dp)), - contentAlignment = Alignment.Center + if (isLoggingIn) { + PurefinWaitingScreen(modifier = modifier) + } else { + Column( + modifier = modifier + .fillMaxSize() + .background(JellyfinBg) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - imageVector = Icons.Default.Movie, // Replace with actual logo resource - contentDescription = "Logo", - tint = Color.White, - modifier = Modifier.size(60.dp) + Spacer(modifier = Modifier.weight(0.5f)) + + // Logo Section + Box( + modifier = Modifier + .size(100.dp) + .background(JellyfinOrange, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Movie, // Replace with actual logo resource + contentDescription = "Logo", + tint = Color.White, + modifier = Modifier.size(60.dp) + ) + } + + Text( + text = "Jellyfin", + color = Color.White, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + text = "PERSONAL MEDIA SYSTEM", + color = TextSecondary, + fontSize = 12.sp, + letterSpacing = 2.sp ) - } - Text( - text = "Jellyfin", - color = Color.White, - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = "PERSONAL MEDIA SYSTEM", - color = TextSecondary, - fontSize = 12.sp, - letterSpacing = 2.sp - ) + Spacer(modifier = Modifier.height(48.dp)) - Spacer(modifier = Modifier.height(48.dp)) + // Form Section + Text( + text = "Connect to Server", + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Start) + ) + Text( + text = "Enter your details to access your library", + color = TextSecondary, + fontSize = 14.sp, + modifier = Modifier + .align(Alignment.Start) + .padding(bottom = 24.dp) + ) - // Form Section - Text( - text = "Connect to Server", - color = Color.White, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.Start) - ) - Text( - text = "Enter your details to access your library", - color = TextSecondary, - fontSize = 14.sp, - modifier = Modifier - .align(Alignment.Start) - .padding(bottom = 24.dp) - ) + PurefinComplexTextField( + label = "Server URL", + value = serverUrl, + onValueChange = { viewModel.setUrl(it) }, + placeholder = "http://192.168.1.100:8096", + leadingIcon = Icons.Default.Storage + ) - PurefinComplexTextField( - label = "Server URL", - value = serverUrl, - onValueChange = { viewModel.setUrl(it) }, - placeholder = "http://192.168.1.100:8096", - leadingIcon = Icons.Default.Storage - ) + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(16.dp)) + PurefinComplexTextField( + label = "Username", + value = username, + onValueChange = { viewModel.setUsername(it) }, + placeholder = "Enter your username", + leadingIcon = Icons.Default.Person + ) - PurefinComplexTextField( - label = "Username", - value = username, - onValueChange = { viewModel.setUsername(it) }, - placeholder = "Enter your username", - leadingIcon = Icons.Default.Person - ) + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(16.dp)) + PurefinPasswordField( + label = "Password", + value = password, + onValueChange = { viewModel.setPassword(it) }, + placeholder = "••••••••", + leadingIcon = Icons.Default.Lock, + ) - PurefinPasswordField( - label = "Password", - value = password, - onValueChange = { viewModel.setPassword(it) }, - placeholder = "••••••••", - leadingIcon = Icons.Default.Lock, - ) + Spacer(modifier = Modifier.height(32.dp)) - Spacer(modifier = Modifier.height(32.dp)) + PurefinTextButton( + content = { Text("Connect") }, + onClick = { + coroutineScope.launch { + isLoggingIn = true + try { + viewModel.login() + } finally { + isLoggingIn = false + } + } + } + ) - PurefinTextButton( - content = { Text("Connect") }, - onClick = { - coroutineScope.launch { - viewModel.login() + Spacer(modifier = Modifier.weight(0.5f)) + + // Footer Links + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = {}) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(18.dp)) + Text(" Discover Servers", color = TextSecondary) + } + } + TextButton(onClick = {}) { + Text("Need Help?", color = TextSecondary) } } - ) - Spacer(modifier = Modifier.weight(0.5f)) - - // Footer Links - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(onClick = {}) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(18.dp)) - Text(" Discover Servers", color = TextSecondary) - } - } - TextButton(onClick = {}) { - Text("Need Help?", color = TextSecondary) - } + Spacer(modifier = Modifier.height(16.dp)) } - - Spacer(modifier = Modifier.height(16.dp)) - } }