diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85ee408bc..a0ca594fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -157,7 +157,8 @@ pinterest-ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = " puppycrawl-checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" } reactivex-rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" } reactivex-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" } -russhwolf-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" } +russhwolf-settings-core = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" } +russhwolf-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "settings" } squareup-leakcanary-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" } squareup-leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" } squareup-leakcanary-watcher = { module = "com.squareup.leakcanary:leakcanary-object-watcher-android", version.ref = "leakcanary" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f52e7a208..de7c8eb07 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index e69de29bb..8c2f7b4ed 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.di.settings + +import android.content.Context +import androidx.preference.PreferenceManager +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import org.koin.core.annotation.Singleton + +/** + * Settings for Android based on SharedPreferences + */ +@Singleton +fun provideSettings(context: Context): Settings = SharedPreferencesSettings( + PreferenceManager.getDefaultSharedPreferences(context) +) diff --git a/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml b/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..fb58b74b1 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_arrow_back.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index db4b09e00..74b75e7cd 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -5,4 +5,7 @@ --> NewPipe + + + Back diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt b/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt new file mode 100644 index 000000000..757f644a1 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/Constants.kt @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app + +object Constants { + const val KEY_STREAMING_SERVICE = "service" +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt new file mode 100644 index 000000000..989810a55 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TopAppBar.kt @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = {} + ) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/commonMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index e69de29bb..698b29f29 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.di.settings + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +/** + * Settings module to access key-value pairs across different platforms. + * See individual platform packages for the declarations included in this module. + */ +@Module +@ComponentScan +@Configuration +class SettingsModule diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt b/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt new file mode 100644 index 000000000..9c29869b1 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/preview/ThemePreviewProvider.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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) + } + } +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt b/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt new file mode 100644 index 000000000..bc79ab34e --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/theme/ServiceTheme.kt @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 + ) +} diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt new file mode 100644 index 000000000..e8dabe314 --- /dev/null +++ b/shared/src/commonTest/kotlin/net/newpipe/app/composable/TopAppBarTest.kt @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 { 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) + } + } + ) + } +} diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt new file mode 100644 index 000000000..7ec719ee8 --- /dev/null +++ b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/ComposeUiTest.kt @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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, + noinline content: @Composable () -> Unit = {}, + onContent: () -> Unit = {} +) { + try { + setContent { + KoinApplication( + configuration = koinConfiguration { + modules(modules) + }, + logLevel = Level.DEBUG, + content = content + ) + } + onContent() + } finally { + stopKoin() + } +} diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt new file mode 100644 index 000000000..86e6494ad --- /dev/null +++ b/shared/src/commonTest/kotlin/net/newpipe/app/theme/ServiceThemeTest.kt @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 { MapSettings() } + } + + withKoin( + modules = listOf(emptySettings), + content = { + assertEquals(currentService(), Service.YOUTUBE) + } + ) + } + + @Test + fun testServiceSwitchingWorks() = runComposeUiTest { + val settings = module { + single { + MapSettings(KEY_STREAMING_SERVICE to "PeerTube") + } + } + + withKoin( + modules = listOf(settings), + content = { + assertNotEquals(currentService(), Service.YOUTUBE) + assertEquals(currentService(), Service.PEERTUBE) + } + ) + } +} diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index e69de29bb..b5f7ef3e3 100644 --- a/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.di.settings + +import com.russhwolf.settings.NSUserDefaultsSettings +import com.russhwolf.settings.Settings +import org.koin.core.annotation.Singleton +import platform.Foundation.NSUserDefaults + +/** + * Settings for iOS based on UserDefaultsSettings + */ +@Singleton +fun provideSettings(): Settings = NSUserDefaultsSettings(NSUserDefaults()) diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index e69de29bb..82a7d426f 100644 --- a/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.di.settings + +import com.russhwolf.settings.PreferencesSettings +import com.russhwolf.settings.Settings +import java.util.prefs.Preferences +import org.koin.core.annotation.Singleton + +/** + * Settings for JVM devices based on Java Preferences + */ +@Singleton +fun provideSettings(): Settings = PreferencesSettings(Preferences.userRoot())