Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Android TV support #260

Merged
merged 20 commits into from
Dec 13, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
[ANDROID_TV] Try to create Android TV UI
kamgurgul committed Nov 26, 2024
commit 7d7400978c972c2d6c3b3e65958d51ef07084286
25 changes: 24 additions & 1 deletion androidApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -12,6 +12,16 @@
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />

<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />

<application
android:name=".CpuInfoApp"
android:allowBackup="true"
@@ -37,6 +47,19 @@

</activity>

<activity
android:name="com.kgurgul.cpuinfo.TvActivity"
android:exported="true"
android:theme="@style/Theme.Launcher"
android:windowSoftInputMode="adjustResize">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>

</activity>

<service
android:name=".features.cputile.CpuTileService"
android:exported="true"
@@ -52,4 +75,4 @@

</application>

</manifest>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.kgurgul.cpuinfo

import android.os.Build
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kgurgul.cpuinfo.features.HostViewModel
import com.kgurgul.cpuinfo.features.TvHostScreen
import com.kgurgul.cpuinfo.ui.shouldUseDarkTheme
import com.kgurgul.cpuinfo.ui.theme.CpuInfoTheme
import com.kgurgul.cpuinfo.ui.theme.DarkColors
import com.kgurgul.cpuinfo.ui.theme.LightColors
import com.kgurgul.cpuinfo.ui.theme.darkPrimary
import com.kgurgul.cpuinfo.ui.theme.lightPrimary
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel

class TvActivity : AppCompatActivity() {

private val viewModel: HostViewModel by viewModel()

override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)

var uiState: HostViewModel.UiState by mutableStateOf(HostViewModel.UiState())
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiStateFlow
.onEach { uiState = it }
.collect()
}
}
splashScreen.setKeepOnScreenCondition { uiState.isLoading }

enableEdgeToEdge()

setContent {
val darkTheme = shouldUseDarkTheme(uiState)
val systemBarScrim = (if (darkTheme) darkPrimary else lightPrimary).toArgb()
DisposableEffect(darkTheme) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(systemBarScrim),
navigationBarStyle = SystemBarStyle.dark(systemBarScrim),
)
onDispose {}
}
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colors = when {
dynamicColor && darkTheme -> {
val dynamicDarkColors = dynamicDarkColorScheme(LocalContext.current)
DarkColors.copy(
secondary = dynamicDarkColors.primaryContainer,
onSecondary = dynamicDarkColors.onPrimaryContainer,
)
}

dynamicColor && !darkTheme -> {
val dynamicLightColors = dynamicLightColorScheme(LocalContext.current)
LightColors.copy(
secondary = dynamicLightColors.secondary,
onSecondary = dynamicLightColors.onSecondary,
)
}

darkTheme -> DarkColors
else -> LightColors
}
CpuInfoTheme(
useDarkTheme = darkTheme,
colors = colors,
) {
TvHostScreen(
viewModel = viewModel,
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.kgurgul.cpuinfo.features

import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.kgurgul.cpuinfo.features.applications.ApplicationsScreen
import com.kgurgul.cpuinfo.features.information.TvInfoContainerScreen
import com.kgurgul.cpuinfo.features.settings.SettingsScreen
import com.kgurgul.cpuinfo.features.temperature.TemperatureScreen
import com.kgurgul.cpuinfo.shared.Res
import com.kgurgul.cpuinfo.shared.applications
import com.kgurgul.cpuinfo.shared.hardware
import com.kgurgul.cpuinfo.shared.ic_android
import com.kgurgul.cpuinfo.shared.ic_cpu
import com.kgurgul.cpuinfo.shared.ic_settings
import com.kgurgul.cpuinfo.shared.ic_temperature
import com.kgurgul.cpuinfo.shared.settings
import com.kgurgul.cpuinfo.shared.temp
import com.kgurgul.cpuinfo.ui.components.CpuNavigationSuiteScaffold
import com.kgurgul.cpuinfo.ui.components.CpuNavigationSuiteScaffoldDefault
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel

@Composable
fun TvHostScreen(
viewModel: HostViewModel = koinViewModel(),
) {
val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle()
TvHostScreen(
uiState = uiState,
)
}

@Composable
fun TvHostScreen(
uiState: HostViewModel.UiState,
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val itemDefaultColors = CpuNavigationSuiteScaffoldDefault.itemDefaultColors()
CpuNavigationSuiteScaffold(
navigationSuiteItems = {
TvHostNavigationItem.bottomNavigationItems(
isApplicationsVisible = uiState.isApplicationSectionVisible,
).forEach { item ->
item(
icon = {
Icon(
painter = painterResource(item.icon),
contentDescription = stringResource(item.label),
)
},
label = {
Text(
text = stringResource(item.label),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
selected = currentDestination?.hierarchy
?.any { it.route == item.route } == true,
onClick = {
navController.navigate(item.route) {
navController.graph.findStartDestination().route?.let {
popUpTo(it) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
},
colors = itemDefaultColors,
)
}
},
) {
NavHost(
navController = navController,
startDestination = TvHostScreen.Information.route,
) {
composable(
route = TvHostScreen.Information.route,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() },
) { TvInfoContainerScreen() }
composable(
route = TvHostScreen.Applications.route,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() },
) { ApplicationsScreen() }
composable(
route = TvHostScreen.Temperatures.route,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() },
) { TemperatureScreen() }
composable(
route = TvHostScreen.Settings.route,
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() },
) { SettingsScreen() }
}
}
}

sealed class TvHostScreen(val route: String) {
data object Information : TvHostScreen("information_route")
data object Applications : TvHostScreen("applications_route")
data object Temperatures : TvHostScreen("temperatures_route")
data object Settings : TvHostScreen("settings_route")
}

data class TvHostNavigationItem(
val label: StringResource,
val icon: DrawableResource,
val route: String,
) {

companion object {
fun bottomNavigationItems(
isApplicationsVisible: Boolean,
): List<HostNavigationItem> {
return buildList {
add(
HostNavigationItem(
label = Res.string.hardware,
icon = Res.drawable.ic_cpu,
route = HostScreen.Information.route,
),
)
if (isApplicationsVisible) {
add(
HostNavigationItem(
label = Res.string.applications,
icon = Res.drawable.ic_android,
route = HostScreen.Applications.route,
),
)
}
add(
HostNavigationItem(
label = Res.string.temp,
icon = Res.drawable.ic_temperature,
route = HostScreen.Temperatures.route,
),
)
add(
HostNavigationItem(
label = Res.string.settings,
icon = Res.drawable.ic_settings,
route = HostScreen.Settings.route,
),
)
}
}
}
}
Loading