feat: add :app-tv module as Android TV starting point
Creates a new :app-tv application module that reuses shared business logic from :core:model, :core:data, :core:player, and :feature:shared, with its own TV-specific UI layer. - TvApplication + TvActivity with full Hilt + Navigation3 wiring - TvHomePage reusing HomePageViewModel from :feature:shared - Copied common UI components (PosterCard, WaitingScreen, etc.) - Copied login screen and form components - TV navigation module with Home + Login route entry builders - Material3 theme copied from mobile (ready for TV customization) - AndroidManifest with LAUNCHER + LEANBACK_LAUNCHER intent filters - Excludes :feature:download (downloads are mobile-only)
90
app-tv/build.gradle.kts
Normal file
@@ -0,0 +1,90 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.tv"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "hu.bbara.purefin.tv"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:player"))
|
||||
implementation(project(":feature:shared"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.jellyfin.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.hilt)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.logging.interceptor)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.medi3.ffmpeg.decoder)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
ksp(libs.hilt.compiler)
|
||||
}
|
||||
21
app-tv/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
39
app-tv/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".TvApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/purefin_logo"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/purefin_logo_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<activity
|
||||
android:name=".TvActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
120
app-tv/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.request.ImageRequest
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
|
||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
item: PosterItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val posterWidth = 144.dp
|
||||
val posterHeight = posterWidth * 3 / 2
|
||||
|
||||
fun openItem(posterItem: PosterItem) {
|
||||
when (posterItem.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
|
||||
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id)
|
||||
BaseItemKind.EPISODE -> {
|
||||
val ep = posterItem.episode!!
|
||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(item.imageUrl)
|
||||
.size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() })
|
||||
.build()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(posterWidth)
|
||||
) {
|
||||
Box() {
|
||||
PurefinAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
.clickable(onClick = { openItem(item) }),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
val m = item.movie!!
|
||||
WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = m.watched,
|
||||
started = (m.progress ?: 0.0) > 0
|
||||
)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val ep = item.episode!!
|
||||
WatchStateIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
watched = ep.watched,
|
||||
started = (ep.progress ?: 0.0) > 0
|
||||
)
|
||||
}
|
||||
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
|
||||
size = 28,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
unwatchedCount = item.series!!.unwatchedEpisodeCount
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PurefinComplexTextField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
leadingIcon: ImageVector? = null,
|
||||
trailingIcon: ImageVector? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
|
||||
leadingIcon = if (leadingIcon != null) {
|
||||
{ Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }
|
||||
} else null,
|
||||
trailingIcon = if (trailingIcon != null) {
|
||||
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) }
|
||||
} else null,
|
||||
visualTransformation = visualTransformation,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = scheme.surfaceVariant,
|
||||
unfocusedContainerColor = scheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = scheme.primary,
|
||||
focusedTextColor = scheme.onSurface,
|
||||
unfocusedTextColor = scheme.onSurface
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PurefinPasswordField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
leadingIcon: ImageVector,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val showField = remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onBackground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
|
||||
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) },
|
||||
trailingIcon =
|
||||
{
|
||||
IconButton(
|
||||
onClick = { showField.value = !showField.value },
|
||||
) {
|
||||
Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = scheme.surfaceVariant,
|
||||
unfocusedContainerColor = scheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = scheme.primary,
|
||||
focusedTextColor = scheme.onSurface,
|
||||
unfocusedTextColor = scheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun PurefinTextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
199
app-tv/src/main/java/hu/bbara/purefin/common/ui/WaitingScreen.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
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.MaterialTheme
|
||||
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 scheme = MaterialTheme.colorScheme
|
||||
val accentColor = scheme.primary
|
||||
val backgroundColor = scheme.background
|
||||
val surfaceColor = scheme.surface
|
||||
val textPrimary = scheme.onSurface
|
||||
val textSecondary = scheme.onSurfaceVariant
|
||||
|
||||
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 = scheme.onPrimary,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "Just a moment",
|
||||
color = textPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "I am doing all I can...",
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A progress bar component for displaying media playback progress.
|
||||
*
|
||||
* @param progress The progress value between 0f and 1f, where 0f is no progress and 1f is complete.
|
||||
* @param foregroundColor The color of the progress indicator.
|
||||
* @param backgroundColor The color of the background/unfilled portion of the progress bar.
|
||||
* @param modifier The modifier to be applied to the Box. Modifier should contain the Alignment.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaProgressBar(
|
||||
progress: Float,
|
||||
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (progress == 0f) return
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(backgroundColor.copy(alpha = 0.2f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(progress)
|
||||
.background(foregroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
/**
|
||||
* Async image that falls back to theme-synced color blocks so loading/error states
|
||||
* stay aligned with PurefinTheme's colorScheme.
|
||||
*/
|
||||
@Composable
|
||||
fun PurefinAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop
|
||||
) {
|
||||
val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant)
|
||||
|
||||
// Convert empty string to null to properly trigger fallback
|
||||
val effectiveModel = when {
|
||||
model is String && model.isEmpty() -> null
|
||||
else -> model
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = effectiveModel,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
placeholder = placeholderPainter,
|
||||
error = placeholderPainter,
|
||||
fallback = placeholderPainter
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PurefinIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Int = 52
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
.background(scheme.secondary)
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
cursorColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(32.dp)),
|
||||
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
|
||||
leadingIcon =
|
||||
{ Icon(imageVector = Icons.Outlined.Search, contentDescription = null, tint = scheme.onSurfaceVariant) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = backgroundColor,
|
||||
unfocusedContainerColor = backgroundColor,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = cursorColor,
|
||||
focusedTextColor = textColor,
|
||||
unfocusedTextColor = textColor,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun UnwatchedEpisodeIndicator(
|
||||
unwatchedCount: Int,
|
||||
foregroundColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.primary,
|
||||
size: Int = 24,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (unwatchedCount == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.border(1.dp, backgroundColor.copy(alpha = 0.8f), CircleShape)
|
||||
.background(backgroundColor.copy(alpha = 0.8f), CircleShape)
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
Text(
|
||||
text = if (unwatchedCount > 9) "9+" else unwatchedCount.toString(),
|
||||
color = foregroundColor.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.W900,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun WatchStateIndicator(
|
||||
watched: Boolean,
|
||||
started: Boolean,
|
||||
watchedColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
watchedBackgroundColor: Color = MaterialTheme.colorScheme.primary,
|
||||
startedColor: Color = MaterialTheme.colorScheme.onSecondary,
|
||||
startedBackgroundColor: Color = MaterialTheme.colorScheme.secondary,
|
||||
size: Int = 24,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
||||
if (watched.not() && started.not()) {
|
||||
return
|
||||
}
|
||||
|
||||
val foregroundColor = if (watched) watchedColor.copy(alpha = 0.8f) else startedColor.copy(alpha = 0.3f)
|
||||
val backgroundColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.3f)
|
||||
val borderColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.8f)
|
||||
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.border(1.dp, borderColor, CircleShape)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
if (watched) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = "Check",
|
||||
tint = foregroundColor,
|
||||
modifier = Modifier
|
||||
.padding(1.dp)
|
||||
.matchParentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WatchStateIndicatorPreview() {
|
||||
Column() {
|
||||
WatchStateIndicator(
|
||||
watched = false,
|
||||
started = false
|
||||
)
|
||||
WatchStateIndicator(
|
||||
watched = true,
|
||||
started = false
|
||||
)
|
||||
WatchStateIndicator(
|
||||
watched = false,
|
||||
started = true
|
||||
)
|
||||
}
|
||||
}
|
||||
214
app-tv/src/main/java/hu/bbara/purefin/login/ui/LoginScreen.kt
Normal file
@@ -0,0 +1,214 @@
|
||||
package hu.bbara.purefin.login.ui
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.feature.shared.login.LoginViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe ViewModel state
|
||||
val serverUrl by viewModel.url.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
var isLoggingIn by remember { mutableStateOf(false) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Logo Section
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie,
|
||||
contentDescription = "Logo",
|
||||
tint = scheme.onPrimary,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Jellyfin",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "PERSONAL MEDIA SYSTEM",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Form Section
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.align(Alignment.Start)
|
||||
)
|
||||
Text(
|
||||
text = "Enter your details to access your library",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = scheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
scheme.errorContainer,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Server URL",
|
||||
value = serverUrl,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUrl(it)
|
||||
},
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Username",
|
||||
value = username,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinPasswordField(
|
||||
label = "Password",
|
||||
value = password,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
placeholder = "••••••••",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text("Connect") },
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
Text(" Discover Servers", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {}) {
|
||||
Text("Need Help?", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
183
app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toPath
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TvActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>
|
||||
|
||||
@Inject
|
||||
lateinit var userSessionRepository: UserSessionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var navigationManager: NavigationManager
|
||||
|
||||
@Inject
|
||||
lateinit var jellyfinApiClient: JellyfinApiClient
|
||||
|
||||
@Inject
|
||||
lateinit var authInterceptor: JellyfinAuthInterceptor
|
||||
|
||||
private val imageOkHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.addNetworkInterceptor(authInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch { init() }
|
||||
configureImageLoader()
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AppTheme {
|
||||
MainApp(
|
||||
userSessionRepository = userSessionRepository,
|
||||
entryBuilders = entryBuilders,
|
||||
navigationManager = navigationManager
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun init() {
|
||||
jellyfinApiClient.updateApiClient()
|
||||
}
|
||||
|
||||
private fun configureImageLoader() {
|
||||
SingletonImageLoader.setSafe { context ->
|
||||
val builder = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = { imageOkHttpClient }
|
||||
)
|
||||
)
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.20)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(context.cacheDir.resolve("image_cache").absolutePath.toPath())
|
||||
.maxSizeBytes(30_000_000)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
|
||||
if (isDebuggable()) {
|
||||
builder.logger(DebugLogger())
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDebuggable(): Boolean =
|
||||
(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
|
||||
@Composable
|
||||
fun MainApp(
|
||||
userSessionRepository: UserSessionRepository,
|
||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||
navigationManager: NavigationManager
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
sessionLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
val appEntryProvider = entryProvider {
|
||||
entryBuilders.forEach { builder -> builder() }
|
||||
}
|
||||
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
backStack.add(command.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalNavigationManager provides navigationManager) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = appEntryProvider
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LoginScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class TvApplication : Application()
|
||||
108
app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package hu.bbara.purefin.tv.home
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import hu.bbara.purefin.feature.shared.home.HomePageViewModel
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeContent
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeDrawerContent
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeMockData
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeNavItem
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeTopBar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
@Composable
|
||||
fun TvHomePage(
|
||||
viewModel: HomePageViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val libraries = viewModel.libraries.collectAsState().value
|
||||
val isOfflineMode = viewModel.isOfflineMode.collectAsState().value
|
||||
val libraryNavItems = libraries.map {
|
||||
TvHomeNavItem(
|
||||
id = it.id,
|
||||
label = it.name,
|
||||
icon = when (it.type) {
|
||||
CollectionType.MOVIES -> Icons.Outlined.Movie
|
||||
CollectionType.TVSHOWS -> Icons.Outlined.Tv
|
||||
else -> Icons.Outlined.Collections
|
||||
},
|
||||
)
|
||||
}
|
||||
val continueWatching = viewModel.continueWatching.collectAsState()
|
||||
val nextUp = viewModel.nextUp.collectAsState()
|
||||
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.onResumed()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.fillMaxSize(),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
||||
drawerContentColor = MaterialTheme.colorScheme.onBackground
|
||||
) {
|
||||
TvHomeDrawerContent(
|
||||
title = "Jellyfin",
|
||||
subtitle = "Library Dashboard",
|
||||
primaryNavItems = libraryNavItems,
|
||||
secondaryNavItems = TvHomeMockData.secondaryNavItems,
|
||||
user = TvHomeMockData.user,
|
||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
|
||||
onLogout = viewModel::logout
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
TvHomeTopBar(
|
||||
onMenuClick = { coroutineScope.launch { drawerState.open() } },
|
||||
isOfflineMode = isOfflineMode,
|
||||
onToggleOfflineMode = viewModel::toggleOfflineMode
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
TvHomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent.value,
|
||||
continueWatching = continueWatching.value,
|
||||
nextUp = nextUp.value,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
@Composable
|
||||
fun TvHomeAvatar(
|
||||
size: Dp,
|
||||
borderWidth: Dp,
|
||||
borderColor: Color,
|
||||
backgroundColor: Color,
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.border(borderWidth, borderColor, CircleShape)
|
||||
.background(backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
@Composable
|
||||
fun TvHomeContent(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
item {
|
||||
TvContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
item {
|
||||
TvNextUpSection(
|
||||
items = nextUp,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
items(
|
||||
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true },
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
TvLibraryPosterSection(
|
||||
title = item.name,
|
||||
items = libraryContent[item.id] ?: emptyList(),
|
||||
action = "See All",
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeDrawer.kt
Normal file
@@ -0,0 +1,204 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TvHomeDrawerContent(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
primaryNavItems: List<TvHomeNavItem>,
|
||||
secondaryNavItems: List<TvHomeNavItem>,
|
||||
user: TvHomeUser,
|
||||
onLibrarySelected: (TvHomeNavItem) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
TvHomeDrawerHeader(
|
||||
title = title,
|
||||
subtitle = subtitle
|
||||
)
|
||||
TvHomeDrawerNav(
|
||||
primaryItems = primaryNavItems,
|
||||
secondaryItems = secondaryNavItems,
|
||||
onLibrarySelected = onLibrarySelected
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TvHomeDrawerFooter(user = user, onLogout = onLogout)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvHomeDrawerHeader(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 16.dp, top = 24.dp, bottom = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(12.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
tint = scheme.onPrimary
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = scheme.onSurfaceVariant.copy(alpha = 0.2f))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvHomeDrawerNav(
|
||||
primaryItems: List<TvHomeNavItem>,
|
||||
secondaryItems: List<TvHomeNavItem>,
|
||||
onLibrarySelected: (TvHomeNavItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
primaryItems.forEach { item ->
|
||||
TvHomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected)
|
||||
}
|
||||
if (secondaryItems.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
secondaryItems.forEach { item ->
|
||||
TvHomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvHomeDrawerNavItem(
|
||||
item: TvHomeNavItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onLibrarySelected: (TvHomeNavItem) -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent
|
||||
val tint = if (item.selected) scheme.primary else scheme.onSurfaceVariant
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.background(background, RoundedCornerShape(12.dp))
|
||||
.clickable { onLibrarySelected(item) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = tint
|
||||
)
|
||||
Text(
|
||||
text = item.label,
|
||||
color = if (item.selected) scheme.primary else scheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvHomeDrawerFooter(
|
||||
user: TvHomeUser,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvHomeAvatar(
|
||||
size = 32.dp,
|
||||
borderWidth = 1.dp,
|
||||
borderColor = scheme.outlineVariant,
|
||||
backgroundColor = scheme.primaryContainer,
|
||||
icon = Icons.Outlined.Person,
|
||||
iconTint = scheme.onBackground
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 12.dp)
|
||||
.clickable { onLogout() }) {
|
||||
Text(
|
||||
text = user.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = user.plan,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
object TvHomeMockData {
|
||||
val user = TvHomeUser(name = "Alex User", plan = "Premium Account")
|
||||
|
||||
val primaryNavItems = listOf(
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search)
|
||||
)
|
||||
|
||||
val secondaryNavItems = listOf(
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
data class TvHomeNavItem(
|
||||
val id: UUID,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val selected: Boolean = false
|
||||
)
|
||||
|
||||
data class TvHomeUser(
|
||||
val name: String,
|
||||
val plan: String
|
||||
)
|
||||
@@ -0,0 +1,347 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.request.ImageRequest
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import kotlin.math.nextUp
|
||||
|
||||
@Composable
|
||||
fun TvContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Continue Watching",
|
||||
action = null
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(items = items) { item ->
|
||||
TvContinueWatchingCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val imageUrl = when (item.type) {
|
||||
BaseItemKind.MOVIE -> item.movie?.heroImageUrl
|
||||
BaseItemKind.EPISODE -> item.episode?.heroImageUrl
|
||||
else -> null
|
||||
}
|
||||
|
||||
val cardWidth = 280.dp
|
||||
val cardHeight = cardWidth * 9 / 16
|
||||
|
||||
fun openItem(item: ContinueWatchingItem) {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = item.episode!!
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
|
||||
.build()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(cardWidth)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
openItem(item)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
MediaProgressBar(
|
||||
progress = item.progress.toFloat().nextUp().div(100),
|
||||
foregroundColor = scheme.onSurface,
|
||||
backgroundColor = scheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = item.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = item.secondaryText,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvNextUpSection(
|
||||
items: List<NextUpItem>,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Next Up",
|
||||
action = null
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(
|
||||
items = items, key = { it.id }) { item ->
|
||||
TvNextUpCard(
|
||||
item = item,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvNextUpCard(
|
||||
item: NextUpItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val imageUrl = item.episode.heroImageUrl
|
||||
|
||||
val cardWidth = 280.dp
|
||||
val cardHeight = cardWidth * 9 / 16
|
||||
|
||||
fun openItem(item: NextUpItem) {
|
||||
val episode = item.episode
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
|
||||
.build()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(cardWidth)
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
openItem(item)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 8.dp, bottom = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(scheme.secondary)
|
||||
.size(36.dp),
|
||||
onClick = {
|
||||
openItem(item)
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = scheme.secondary,
|
||||
contentColor = scheme.onSecondary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = item.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = item.secondaryText,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvLibraryPosterSection(
|
||||
title: String,
|
||||
items: List<PosterItem>,
|
||||
action: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
TvSectionHeader(
|
||||
title = title,
|
||||
action = action
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(
|
||||
items = items, key = { it.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvSectionHeader(
|
||||
title: String,
|
||||
action: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
onActionClick: () -> Unit = {}
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (action != null) {
|
||||
Text(
|
||||
text = action,
|
||||
color = scheme.primary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.clickable { onActionClick() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Cloud
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import hu.bbara.purefin.common.ui.components.PurefinIconButton
|
||||
import hu.bbara.purefin.common.ui.components.SearchField
|
||||
|
||||
@Composable
|
||||
fun TvHomeTopBar(
|
||||
onMenuClick: () -> Unit,
|
||||
isOfflineMode: Boolean,
|
||||
onToggleOfflineMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(scheme.background.copy(alpha = 0.95f))
|
||||
.zIndex(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
PurefinIconButton(
|
||||
icon = Icons.Outlined.Menu,
|
||||
contentDescription = "Menu",
|
||||
onClick = onMenuClick,
|
||||
)
|
||||
SearchField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
placeholder = "Search",
|
||||
backgroundColor = scheme.secondaryContainer,
|
||||
textColor = scheme.onSecondaryContainer,
|
||||
cursorColor = scheme.onSecondaryContainer,
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
)
|
||||
PurefinIconButton(
|
||||
icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud,
|
||||
contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline",
|
||||
onClick = onToggleOfflineMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package hu.bbara.purefin.tv.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityRetainedComponent::class)
|
||||
object TvNavigationModule {
|
||||
|
||||
@IntoSet
|
||||
@Provides
|
||||
fun provideTvHomeEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvHomeSection()
|
||||
}
|
||||
|
||||
@IntoSet
|
||||
@Provides
|
||||
fun provideTvLoginEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvLoginSection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package hu.bbara.purefin.tv.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
import hu.bbara.purefin.tv.home.TvHomePage
|
||||
|
||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
entry<Route.Home> {
|
||||
TvHomePage()
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
}
|
||||
}
|
||||
218
app-tv/src/main/java/hu/bbara/purefin/ui/theme/Color.kt
Normal file
@@ -0,0 +1,218 @@
|
||||
package hu.bbara.purefin.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val primaryLight = Color(0xFF8A5021)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFFFDCC5)
|
||||
val onPrimaryContainerLight = Color(0xFF6D390B)
|
||||
val secondaryLight = Color(0xFF755945)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFFFDCC5)
|
||||
val onSecondaryContainerLight = Color(0xFF5B412F)
|
||||
val tertiaryLight = Color(0xFF5E6135)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFE4E6AE)
|
||||
val onTertiaryContainerLight = Color(0xFF464920)
|
||||
val errorLight = Color(0xFFBA1A1A)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFFFDAD6)
|
||||
val onErrorContainerLight = Color(0xFF93000A)
|
||||
val backgroundLight = Color(0xFFFFF8F5)
|
||||
val onBackgroundLight = Color(0xFF221A15)
|
||||
val surfaceLight = Color(0xFFFFF8F5)
|
||||
val onSurfaceLight = Color(0xFF221A15)
|
||||
val surfaceVariantLight = Color(0xFFF3DFD2)
|
||||
val onSurfaceVariantLight = Color(0xFF52443B)
|
||||
val outlineLight = Color(0xFF84746A)
|
||||
val outlineVariantLight = Color(0xFFD6C3B7)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF382F29)
|
||||
val inverseOnSurfaceLight = Color(0xFFFEEEE4)
|
||||
val inversePrimaryLight = Color(0xFFFFB783)
|
||||
val surfaceDimLight = Color(0xFFE7D7CE)
|
||||
val surfaceBrightLight = Color(0xFFFFF8F5)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFFFF1E9)
|
||||
val surfaceContainerLight = Color(0xFFFBEBE1)
|
||||
val surfaceContainerHighLight = Color(0xFFF5E5DC)
|
||||
val surfaceContainerHighestLight = Color(0xFFF0DFD6)
|
||||
|
||||
val primaryLightMediumContrast = Color(0xFF572A00)
|
||||
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightMediumContrast = Color(0xFF9B5E2E)
|
||||
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightMediumContrast = Color(0xFF493120)
|
||||
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightMediumContrast = Color(0xFF856753)
|
||||
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightMediumContrast = Color(0xFF363911)
|
||||
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightMediumContrast = Color(0xFF6D7042)
|
||||
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorLightMediumContrast = Color(0xFF740006)
|
||||
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
|
||||
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightMediumContrast = Color(0xFFFFF8F5)
|
||||
val onBackgroundLightMediumContrast = Color(0xFF221A15)
|
||||
val surfaceLightMediumContrast = Color(0xFFFFF8F5)
|
||||
val onSurfaceLightMediumContrast = Color(0xFF17100B)
|
||||
val surfaceVariantLightMediumContrast = Color(0xFFF3DFD2)
|
||||
val onSurfaceVariantLightMediumContrast = Color(0xFF40342B)
|
||||
val outlineLightMediumContrast = Color(0xFF5E5046)
|
||||
val outlineVariantLightMediumContrast = Color(0xFF7A6A60)
|
||||
val scrimLightMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightMediumContrast = Color(0xFF382F29)
|
||||
val inverseOnSurfaceLightMediumContrast = Color(0xFFFEEEE4)
|
||||
val inversePrimaryLightMediumContrast = Color(0xFFFFB783)
|
||||
val surfaceDimLightMediumContrast = Color(0xFFD3C4BB)
|
||||
val surfaceBrightLightMediumContrast = Color(0xFFFFF8F5)
|
||||
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightMediumContrast = Color(0xFFFFF1E9)
|
||||
val surfaceContainerLightMediumContrast = Color(0xFFF5E5DC)
|
||||
val surfaceContainerHighLightMediumContrast = Color(0xFFEADAD1)
|
||||
val surfaceContainerHighestLightMediumContrast = Color(0xFFDECFC6)
|
||||
|
||||
val primaryLightHighContrast = Color(0xFF492200)
|
||||
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightHighContrast = Color(0xFF703C0D)
|
||||
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightHighContrast = Color(0xFF3D2717)
|
||||
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightHighContrast = Color(0xFF5D4431)
|
||||
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightHighContrast = Color(0xFF2C2E07)
|
||||
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightHighContrast = Color(0xFF494C22)
|
||||
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorLightHighContrast = Color(0xFF600004)
|
||||
val onErrorLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightHighContrast = Color(0xFF98000A)
|
||||
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightHighContrast = Color(0xFFFFF8F5)
|
||||
val onBackgroundLightHighContrast = Color(0xFF221A15)
|
||||
val surfaceLightHighContrast = Color(0xFFFFF8F5)
|
||||
val onSurfaceLightHighContrast = Color(0xFF000000)
|
||||
val surfaceVariantLightHighContrast = Color(0xFFF3DFD2)
|
||||
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
|
||||
val outlineLightHighContrast = Color(0xFF362A22)
|
||||
val outlineVariantLightHighContrast = Color(0xFF54463E)
|
||||
val scrimLightHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightHighContrast = Color(0xFF382F29)
|
||||
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
|
||||
val inversePrimaryLightHighContrast = Color(0xFFFFB783)
|
||||
val surfaceDimLightHighContrast = Color(0xFFC5B6AD)
|
||||
val surfaceBrightLightHighContrast = Color(0xFFFFF8F5)
|
||||
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightHighContrast = Color(0xFFFEEEE4)
|
||||
val surfaceContainerLightHighContrast = Color(0xFFF0DFD6)
|
||||
val surfaceContainerHighLightHighContrast = Color(0xFFE1D1C8)
|
||||
val surfaceContainerHighestLightHighContrast = Color(0xFFD3C4BB)
|
||||
|
||||
val primaryDark = Color(0xFFFFB783)
|
||||
val onPrimaryDark = Color(0xFF4F2500)
|
||||
val primaryContainerDark = Color(0xFF6D390B)
|
||||
val onPrimaryContainerDark = Color(0xFFFFDCC5)
|
||||
val secondaryDark = Color(0xFFE4BFA7)
|
||||
val onSecondaryDark = Color(0xFF422B1B)
|
||||
val secondaryContainerDark = Color(0xFF5B412F)
|
||||
val onSecondaryContainerDark = Color(0xFFFFDCC5)
|
||||
val tertiaryDark = Color(0xFFC7CA94)
|
||||
val onTertiaryDark = Color(0xFF30330B)
|
||||
val tertiaryContainerDark = Color(0xFF464920)
|
||||
val onTertiaryContainerDark = Color(0xFFE4E6AE)
|
||||
val errorDark = Color(0xFFFFB4AB)
|
||||
val onErrorDark = Color(0xFF690005)
|
||||
val errorContainerDark = Color(0xFF93000A)
|
||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
val backgroundDark = Color(0xFF19120D)
|
||||
val onBackgroundDark = Color(0xFFF0DFD6)
|
||||
val surfaceDark = Color(0xFF19120D)
|
||||
val onSurfaceDark = Color(0xFFF0DFD6)
|
||||
val surfaceVariantDark = Color(0xFF52443B)
|
||||
val onSurfaceVariantDark = Color(0xFFD6C3B7)
|
||||
val outlineDark = Color(0xFF9F8D83)
|
||||
val outlineVariantDark = Color(0xFF52443B)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFF0DFD6)
|
||||
val inverseOnSurfaceDark = Color(0xFF382F29)
|
||||
val inversePrimaryDark = Color(0xFF8A5021)
|
||||
val surfaceDimDark = Color(0xFF19120D)
|
||||
val surfaceBrightDark = Color(0xFF413731)
|
||||
val surfaceContainerLowestDark = Color(0xFF140D08)
|
||||
val surfaceContainerLowDark = Color(0xFF221A15)
|
||||
val surfaceContainerDark = Color(0xFF261E18)
|
||||
val surfaceContainerHighDark = Color(0xFF312822)
|
||||
val surfaceContainerHighestDark = Color(0xFF3C332D)
|
||||
|
||||
val primaryDarkMediumContrast = Color(0xFFFFD4B7)
|
||||
val onPrimaryDarkMediumContrast = Color(0xFF3F1C00)
|
||||
val primaryContainerDarkMediumContrast = Color(0xFFC5814E)
|
||||
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val secondaryDarkMediumContrast = Color(0xFFFBD5BC)
|
||||
val onSecondaryDarkMediumContrast = Color(0xFF362111)
|
||||
val secondaryContainerDarkMediumContrast = Color(0xFFAB8A74)
|
||||
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val tertiaryDarkMediumContrast = Color(0xFFDDE0A9)
|
||||
val onTertiaryDarkMediumContrast = Color(0xFF252803)
|
||||
val tertiaryContainerDarkMediumContrast = Color(0xFF919463)
|
||||
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val errorDarkMediumContrast = Color(0xFFFFD2CC)
|
||||
val onErrorDarkMediumContrast = Color(0xFF540003)
|
||||
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
|
||||
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val backgroundDarkMediumContrast = Color(0xFF19120D)
|
||||
val onBackgroundDarkMediumContrast = Color(0xFFF0DFD6)
|
||||
val surfaceDarkMediumContrast = Color(0xFF19120D)
|
||||
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkMediumContrast = Color(0xFF52443B)
|
||||
val onSurfaceVariantDarkMediumContrast = Color(0xFFEDD8CC)
|
||||
val outlineDarkMediumContrast = Color(0xFFC1AEA3)
|
||||
val outlineVariantDarkMediumContrast = Color(0xFF9E8D82)
|
||||
val scrimDarkMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkMediumContrast = Color(0xFFF0DFD6)
|
||||
val inverseOnSurfaceDarkMediumContrast = Color(0xFF312822)
|
||||
val inversePrimaryDarkMediumContrast = Color(0xFF6F3A0C)
|
||||
val surfaceDimDarkMediumContrast = Color(0xFF19120D)
|
||||
val surfaceBrightDarkMediumContrast = Color(0xFF4D423C)
|
||||
val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0603)
|
||||
val surfaceContainerLowDarkMediumContrast = Color(0xFF241C16)
|
||||
val surfaceContainerDarkMediumContrast = Color(0xFF2F2620)
|
||||
val surfaceContainerHighDarkMediumContrast = Color(0xFF3A312B)
|
||||
val surfaceContainerHighestDarkMediumContrast = Color(0xFF463C35)
|
||||
|
||||
val primaryDarkHighContrast = Color(0xFFFFECE2)
|
||||
val onPrimaryDarkHighContrast = Color(0xFF000000)
|
||||
val primaryContainerDarkHighContrast = Color(0xFFFEB27A)
|
||||
val onPrimaryContainerDarkHighContrast = Color(0xFF170700)
|
||||
val secondaryDarkHighContrast = Color(0xFFFFECE2)
|
||||
val onSecondaryDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryContainerDarkHighContrast = Color(0xFFE0BBA3)
|
||||
val onSecondaryContainerDarkHighContrast = Color(0xFF170700)
|
||||
val tertiaryDarkHighContrast = Color(0xFFF1F4BB)
|
||||
val onTertiaryDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryContainerDarkHighContrast = Color(0xFFC3C691)
|
||||
val onTertiaryContainerDarkHighContrast = Color(0xFF0B0C00)
|
||||
val errorDarkHighContrast = Color(0xFFFFECE9)
|
||||
val onErrorDarkHighContrast = Color(0xFF000000)
|
||||
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
|
||||
val onErrorContainerDarkHighContrast = Color(0xFF220001)
|
||||
val backgroundDarkHighContrast = Color(0xFF19120D)
|
||||
val onBackgroundDarkHighContrast = Color(0xFFF0DFD6)
|
||||
val surfaceDarkHighContrast = Color(0xFF19120D)
|
||||
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkHighContrast = Color(0xFF52443B)
|
||||
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val outlineDarkHighContrast = Color(0xFFFFECE2)
|
||||
val outlineVariantDarkHighContrast = Color(0xFFD2BFB3)
|
||||
val scrimDarkHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkHighContrast = Color(0xFFF0DFD6)
|
||||
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
|
||||
val inversePrimaryDarkHighContrast = Color(0xFF6F3A0C)
|
||||
val surfaceDimDarkHighContrast = Color(0xFF19120D)
|
||||
val surfaceBrightDarkHighContrast = Color(0xFF594E47)
|
||||
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
|
||||
val surfaceContainerLowDarkHighContrast = Color(0xFF261E18)
|
||||
val surfaceContainerDarkHighContrast = Color(0xFF382F29)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF433933)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF4F453E)
|
||||
275
app-tv/src/main/java/hu/bbara/purefin/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,275 @@
|
||||
package hu.bbara.purefin.ui.theme
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
private val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightMediumContrast,
|
||||
onPrimary = onPrimaryLightMediumContrast,
|
||||
primaryContainer = primaryContainerLightMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
|
||||
secondary = secondaryLightMediumContrast,
|
||||
onSecondary = onSecondaryLightMediumContrast,
|
||||
secondaryContainer = secondaryContainerLightMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
|
||||
tertiary = tertiaryLightMediumContrast,
|
||||
onTertiary = onTertiaryLightMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerLightMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
|
||||
error = errorLightMediumContrast,
|
||||
onError = onErrorLightMediumContrast,
|
||||
errorContainer = errorContainerLightMediumContrast,
|
||||
onErrorContainer = onErrorContainerLightMediumContrast,
|
||||
background = backgroundLightMediumContrast,
|
||||
onBackground = onBackgroundLightMediumContrast,
|
||||
surface = surfaceLightMediumContrast,
|
||||
onSurface = onSurfaceLightMediumContrast,
|
||||
surfaceVariant = surfaceVariantLightMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
|
||||
outline = outlineLightMediumContrast,
|
||||
outlineVariant = outlineVariantLightMediumContrast,
|
||||
scrim = scrimLightMediumContrast,
|
||||
inverseSurface = inverseSurfaceLightMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
|
||||
inversePrimary = inversePrimaryLightMediumContrast,
|
||||
surfaceDim = surfaceDimLightMediumContrast,
|
||||
surfaceBright = surfaceBrightLightMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
|
||||
surfaceContainer = surfaceContainerLightMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightHighContrast,
|
||||
onPrimary = onPrimaryLightHighContrast,
|
||||
primaryContainer = primaryContainerLightHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightHighContrast,
|
||||
secondary = secondaryLightHighContrast,
|
||||
onSecondary = onSecondaryLightHighContrast,
|
||||
secondaryContainer = secondaryContainerLightHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightHighContrast,
|
||||
tertiary = tertiaryLightHighContrast,
|
||||
onTertiary = onTertiaryLightHighContrast,
|
||||
tertiaryContainer = tertiaryContainerLightHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightHighContrast,
|
||||
error = errorLightHighContrast,
|
||||
onError = onErrorLightHighContrast,
|
||||
errorContainer = errorContainerLightHighContrast,
|
||||
onErrorContainer = onErrorContainerLightHighContrast,
|
||||
background = backgroundLightHighContrast,
|
||||
onBackground = onBackgroundLightHighContrast,
|
||||
surface = surfaceLightHighContrast,
|
||||
onSurface = onSurfaceLightHighContrast,
|
||||
surfaceVariant = surfaceVariantLightHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightHighContrast,
|
||||
outline = outlineLightHighContrast,
|
||||
outlineVariant = outlineVariantLightHighContrast,
|
||||
scrim = scrimLightHighContrast,
|
||||
inverseSurface = inverseSurfaceLightHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightHighContrast,
|
||||
inversePrimary = inversePrimaryLightHighContrast,
|
||||
surfaceDim = surfaceDimLightHighContrast,
|
||||
surfaceBright = surfaceBrightLightHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightHighContrast,
|
||||
surfaceContainer = surfaceContainerLightHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
)
|
||||
|
||||
private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkMediumContrast,
|
||||
onPrimary = onPrimaryDarkMediumContrast,
|
||||
primaryContainer = primaryContainerDarkMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
|
||||
secondary = secondaryDarkMediumContrast,
|
||||
onSecondary = onSecondaryDarkMediumContrast,
|
||||
secondaryContainer = secondaryContainerDarkMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
|
||||
tertiary = tertiaryDarkMediumContrast,
|
||||
onTertiary = onTertiaryDarkMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
|
||||
error = errorDarkMediumContrast,
|
||||
onError = onErrorDarkMediumContrast,
|
||||
errorContainer = errorContainerDarkMediumContrast,
|
||||
onErrorContainer = onErrorContainerDarkMediumContrast,
|
||||
background = backgroundDarkMediumContrast,
|
||||
onBackground = onBackgroundDarkMediumContrast,
|
||||
surface = surfaceDarkMediumContrast,
|
||||
onSurface = onSurfaceDarkMediumContrast,
|
||||
surfaceVariant = surfaceVariantDarkMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
|
||||
outline = outlineDarkMediumContrast,
|
||||
outlineVariant = outlineVariantDarkMediumContrast,
|
||||
scrim = scrimDarkMediumContrast,
|
||||
inverseSurface = inverseSurfaceDarkMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
|
||||
inversePrimary = inversePrimaryDarkMediumContrast,
|
||||
surfaceDim = surfaceDimDarkMediumContrast,
|
||||
surfaceBright = surfaceBrightDarkMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
|
||||
surfaceContainer = surfaceContainerDarkMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkHighContrast,
|
||||
onPrimary = onPrimaryDarkHighContrast,
|
||||
primaryContainer = primaryContainerDarkHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
|
||||
secondary = secondaryDarkHighContrast,
|
||||
onSecondary = onSecondaryDarkHighContrast,
|
||||
secondaryContainer = secondaryContainerDarkHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
|
||||
tertiary = tertiaryDarkHighContrast,
|
||||
onTertiary = onTertiaryDarkHighContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
|
||||
error = errorDarkHighContrast,
|
||||
onError = onErrorDarkHighContrast,
|
||||
errorContainer = errorContainerDarkHighContrast,
|
||||
onErrorContainer = onErrorContainerDarkHighContrast,
|
||||
background = backgroundDarkHighContrast,
|
||||
onBackground = onBackgroundDarkHighContrast,
|
||||
surface = surfaceDarkHighContrast,
|
||||
onSurface = onSurfaceDarkHighContrast,
|
||||
surfaceVariant = surfaceVariantDarkHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
|
||||
outline = outlineDarkHighContrast,
|
||||
outlineVariant = outlineVariantDarkHighContrast,
|
||||
scrim = scrimDarkHighContrast,
|
||||
inverseSurface = inverseSurfaceDarkHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
|
||||
inversePrimary = inversePrimaryDarkHighContrast,
|
||||
surfaceDim = surfaceDimDarkHighContrast,
|
||||
surfaceBright = surfaceBrightDarkHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
|
||||
surfaceContainer = surfaceContainerDarkHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ColorFamily(
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val colorContainer: Color,
|
||||
val onColorContainer: Color
|
||||
)
|
||||
|
||||
val unspecified_scheme = ColorFamily(
|
||||
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> darkScheme
|
||||
else -> lightScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
5
app-tv/src/main/java/hu/bbara/purefin/ui/theme/Type.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package hu.bbara.purefin.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val AppTypography = Typography()
|
||||
74
app-tv/src/main/res/drawable/purefin_logo_background.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
5
app-tv/src/main/res/mipmap-anydpi-v26/purefin_logo.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/purefin_logo_background"/>
|
||||
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/purefin_logo_background"/>
|
||||
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
</adaptive-icon>
|
||||
6
app-tv/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app-tv/src/main/res/mipmap-hdpi/purefin_logo_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app-tv/src/main/res/mipmap-hdpi/purefin_logo_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app-tv/src/main/res/mipmap-mdpi/purefin_logo.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app-tv/src/main/res/mipmap-mdpi/purefin_logo_foreground.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
app-tv/src/main/res/mipmap-mdpi/purefin_logo_round.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
app-tv/src/main/res/mipmap-xhdpi/purefin_logo.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
app-tv/src/main/res/mipmap-xhdpi/purefin_logo_foreground.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
app-tv/src/main/res/mipmap-xhdpi/purefin_logo_round.webp
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
app-tv/src/main/res/mipmap-xxhdpi/purefin_logo.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_foreground.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app-tv/src/main/res/mipmap-xxhdpi/purefin_logo_round.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_foreground.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
app-tv/src/main/res/mipmap-xxxhdpi/purefin_logo_round.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
3
app-tv/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Purefin</string>
|
||||
</resources>
|
||||
5
app-tv/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Purefin" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app-tv/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app-tv/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -21,6 +21,7 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "Purefin"
|
||||
include(":app")
|
||||
include(":app-tv")
|
||||
include(":core:model")
|
||||
include(":core:data")
|
||||
include(":feature:download")
|
||||
|
||||