shared: Add top app bar composable to reflect different active services

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta 2026-05-22 18:29:13 +08:00
parent dfc3f4b9f3
commit 0f2bbf11ff
11 changed files with 453 additions and 2 deletions

View file

@ -107,12 +107,13 @@ kotlin {
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.annotations)
implementation(libs.russhwolf.settings)
implementation(libs.russhwolf.settings.core)
}
}
commonTest.dependencies {
implementation(libs.kotlin.test.core)
implementation(libs.jetbrains.compose.test.ui)
implementation(libs.russhwolf.settings.test)
}
androidMain.dependencies {
implementation(libs.jetbrains.compose.preview)

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View file

@ -5,4 +5,7 @@
-->
<resources>
<string name="app_name">NewPipe</string>
<!-- TopAppBar -->
<string name="navigate_back">Back</string>
</resources>

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app
object Constants {
const val KEY_STREAMING_SERVICE = "service"
}

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.currentServiceTopAppBarColors
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.app_name
import newpipe.shared.generated.resources.ic_arrow_back
import newpipe.shared.generated.resources.navigate_back
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
/**
* A top app bar composable to be used with Scaffold
* @param modifier The modifier to be applied to the composable
* @param title Title of the screen
* @param navigationIcon Icon for the navigation button
* @param onNavigateUp Action when user clicks the navigation icon
* @param actions Actions to display on the top app bar (for e.g. menu)
*/
@Composable
fun TopAppBar(
modifier: Modifier = Modifier,
title: String? = null,
navigationIcon: Painter = painterResource(Res.drawable.ic_arrow_back),
onNavigateUp: (() -> Unit)? = null,
colors: TopAppBarColors = currentServiceTopAppBarColors(),
actions: @Composable (RowScope.() -> Unit) = {}
) {
TopAppBar(
modifier = modifier,
colors = colors,
title = { if (title != null) Text(text = title) },
navigationIcon = {
if (onNavigateUp != null) {
IconButton(onClick = onNavigateUp) {
Icon(
painter = navigationIcon,
contentDescription = stringResource(Res.string.navigate_back)
)
}
}
},
actions = actions
)
}
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun TopAppBarPreview() {
TopAppBar(
title = stringResource(Res.string.app_name),
onNavigateUp = {}
)
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.preview
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewWrapperProvider
import net.newpipe.app.theme.AppTheme
/**
* Default preview provider for composables
*/
class ThemePreviewProvider : PreviewWrapperProvider {
@Composable
override fun Wrap(content: @Composable (() -> Unit)) {
AppTheme {
Surface(content = content)
}
}
}

View file

@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import com.russhwolf.settings.Settings
import net.newpipe.app.Constants.KEY_STREAMING_SERVICE
import org.koin.compose.koinInject
val youTubeLightScheme = lightColorScheme(
primaryContainer = Color(0xFFE53935),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val youTubeDarkScheme = darkColorScheme(
primaryContainer = Color(0xFF992722),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val soundCloudLightScheme = lightColorScheme(
primaryContainer = Color(0xFFF57C00),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val soundCloudDarkScheme = darkColorScheme(
primaryContainer = Color(0xFFA35300),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val mediaCCCLightScheme = lightColorScheme(
primaryContainer = Color(0xFF9E9E9E),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val mediaCCCDarkScheme = darkColorScheme(
primaryContainer = Color(0xFF878787),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val peerTubeLightScheme = lightColorScheme(
primaryContainer = Color(0xFFFF6F00),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val peerTubeDarkScheme = darkColorScheme(
primaryContainer = Color(0xFFA34700),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val bandCampLightScheme = lightColorScheme(
primaryContainer = Color(0xFF17A0C4),
onPrimaryContainer = Color(0xFFFFFFFF)
)
val bandCampDarkScheme = darkColorScheme(
primaryContainer = Color(0xFF1383A1),
onPrimaryContainer = Color(0xFFFFFFFF)
)
/**
* Supported services in the NewPipe app and minor information about them for UI decisions.
* @property serviceId ID of the service as defined in NewPipeExtractor
* @property serviceName Name of the service as defined in NewPipeExtractor
* @property lightScheme Light color scheme to reflect the brand
* @property darkScheme Dark color scheme to reflect the brand
* @property isSchemeColorDensityLight Whether this brand's color schemes are of lighter density.
*/
enum class Service(
val serviceId: Int,
val serviceName: String,
val lightScheme: ColorScheme,
val darkScheme: ColorScheme,
val isSchemeColorDensityLight: Boolean = false
) {
YOUTUBE(
serviceId = 0,
serviceName = "YouTube",
lightScheme = youTubeLightScheme,
darkScheme = youTubeDarkScheme
),
SOUNDCLOUD(
serviceId = 1,
serviceName = "SoundCloud",
lightScheme = soundCloudLightScheme,
darkScheme = soundCloudDarkScheme
),
MEDIA_CCC(
serviceId = 2,
serviceName = "media.ccc.de",
lightScheme = mediaCCCLightScheme,
darkScheme = mediaCCCDarkScheme
),
PEERTUBE(
serviceId = 3,
serviceName = "PeerTube",
lightScheme = peerTubeLightScheme,
darkScheme = peerTubeDarkScheme
),
BANDCAMP(
serviceId = 4,
serviceName = "Bandcamp",
lightScheme = bandCampLightScheme,
darkScheme = bandCampDarkScheme
)
}
/**
* Currently active/selected service by user
*/
@Composable
fun currentService(settings: Settings = koinInject()): Service {
return Service.entries.find { service ->
service.serviceName == settings.getString(
KEY_STREAMING_SERVICE,
Service.YOUTUBE.serviceName
)
}!!
}
/**
* Currently active/selected service's color that can be used to represent it.
* Fallbacks to YouTube on preview.
*/
@Composable
fun currentServiceScheme(
isPreview: Boolean = LocalInspectionMode.current,
useDarkTheme: Boolean = isSystemInDarkTheme(),
service: Service = if (isPreview) Service.YOUTUBE else currentService()
): ColorScheme {
return when {
useDarkTheme -> service.darkScheme
else -> service.lightScheme
}
}
/**
* Top app bar colors to represent the currently active service.
* Fallbacks to YouTube on preview.
*/
@Composable
fun currentServiceTopAppBarColors(
serviceScheme: ColorScheme = currentServiceScheme()
): TopAppBarColors {
return TopAppBarDefaults.topAppBarColors(
containerColor = serviceScheme.primaryContainer,
scrolledContainerColor = serviceScheme.primaryContainer,
navigationIconContentColor = serviceScheme.onPrimaryContainer,
titleContentColor = serviceScheme.onPrimaryContainer,
subtitleContentColor = serviceScheme.onPrimaryContainer,
actionIconContentColor = serviceScheme.onPrimaryContainer
)
}

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertTrue
import net.newpipe.app.extensions.withKoin
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.app_name
import newpipe.shared.generated.resources.navigate_back
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.dsl.module
@OptIn(ExperimentalTestApi::class)
class TopAppBarTest {
private val emptySettings = module {
single<Settings> { MapSettings() }
}
@Test
fun testTopAppBarHasNoNavigationByDefault() = runComposeUiTest {
withKoin(
modules = listOf(emptySettings),
content = {
TopAppBar()
},
onContent = {
onNodeWithContentDescription(getString(Res.string.navigate_back))
.assertDoesNotExist()
}
)
}
@Test
fun testTopAppBarCanHaveNavigation() = runComposeUiTest {
var navigationBackClicked = false
withKoin(
modules = listOf(emptySettings),
content = {
TopAppBar(
title = stringResource(Res.string.app_name),
onNavigateUp = { navigationBackClicked = true }
)
},
onContent = {
onNodeWithText(getString(Res.string.app_name)).assertIsDisplayed()
onNodeWithContentDescription(getString(Res.string.navigate_back)).apply {
assertExists()
performClick()
assertTrue(navigationBackClicked)
}
}
)
}
}

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.extensions
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import org.koin.compose.KoinApplication
import org.koin.core.context.stopKoin
import org.koin.core.logger.Level
import org.koin.core.module.Module
import org.koin.dsl.koinConfiguration
/**
* Sets the content for the UI test wrapped inside Koin
* @param modules Modules for Koin to init for the composables
* @param content Composable content for testing
* @param onContent Non-composable code for testing, maybe dependent upon composable code
*/
@OptIn(ExperimentalTestApi::class)
inline fun ComposeUiTest.withKoin(
modules: List<Module>,
noinline content: @Composable () -> Unit = {},
onContent: () -> Unit = {}
) {
try {
setContent {
KoinApplication(
configuration = koinConfiguration {
modules(modules)
},
logLevel = Level.DEBUG,
content = content
)
}
onContent()
} finally {
stopKoin()
}
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.theme
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import net.newpipe.app.Constants.KEY_STREAMING_SERVICE
import net.newpipe.app.extensions.withKoin
import org.koin.dsl.module
@OptIn(ExperimentalTestApi::class)
class ServiceThemeTest {
@Test
fun testDefaultServiceIsYouTube() = runComposeUiTest {
val emptySettings = module {
single<Settings> { MapSettings() }
}
withKoin(
modules = listOf(emptySettings),
content = {
assertEquals(currentService(), Service.YOUTUBE)
}
)
}
@Test
fun testServiceSwitchingWorks() = runComposeUiTest {
val settings = module {
single<Settings> {
MapSettings(KEY_STREAMING_SERVICE to "PeerTube")
}
}
withKoin(
modules = listOf(settings),
content = {
assertNotEquals(currentService(), Service.YOUTUBE)
assertEquals(currentService(), Service.PEERTUBE)
}
)
}
}