Implement KaRemote Android app for Karadio32 control

- Add Jetpack Compose UI with Material 3 theming
- Implement Karadio32 HTTP API client (play/stop, volume, stations)
- Integrate RadioBrowser.info API for station icons and metadata
- Add settings screen with server address and theme configuration
- Support light/dark/system theme modes and 6 color themes
- Configure network security for local HTTP connections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pavel Baksy 2025-11-21 15:10:19 +01:00
parent 605726d909
commit f658c45c23
28 changed files with 2334 additions and 52 deletions

8
.gitignore vendored
View File

@ -1,12 +1,8 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
.DS_Store
/build
/captures

View File

@ -5,11 +5,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Build Commands
```bash
# Build debug APK
./gradlew assembleDebug
# Build debug APK (requires Java 17+)
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleRelease
# Run unit tests
./gradlew test
@ -34,9 +34,69 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Target/Compile SDK**: 36 (Android 15)
- **Kotlin JVM Target**: 11
- **Build System**: Gradle with Kotlin DSL, version catalog in `gradle/libs.versions.toml`
- **Required JDK**: 17+ (for Hilt plugin)
## Architecture
This is a single-module Android app using Material 3 with DayNight theme support. Source code goes in `app/src/main/java/cz/bugsy/karemote/`.
MVVM architecture with Jetpack Compose and Material 3.
Currently a starter template - no activities, networking, persistence, or architecture patterns are configured yet.
### Tech Stack
- **UI**: Jetpack Compose with Material 3
- **DI**: Hilt
- **Networking**: Retrofit + OkHttp
- **Image Loading**: Coil
- **Persistence**: DataStore Preferences
- **Navigation**: Navigation Compose
### Package Structure
```
cz.bugsy.karemote/
├── data/
│ ├── api/ # Retrofit API interfaces
│ │ ├── KaradioApi.kt
│ │ └── RadioBrowserApi.kt
│ ├── model/ # Data classes
│ │ ├── AppSettings.kt
│ │ ├── KaradioModels.kt
│ │ └── RadioBrowserModels.kt
│ └── repository/ # Repositories
│ ├── KaradioRepository.kt
│ ├── RadioBrowserRepository.kt
│ └── SettingsRepository.kt
├── di/ # Hilt DI modules
│ └── NetworkModule.kt
├── ui/
│ ├── theme/ # Theme definitions
│ │ ├── Color.kt
│ │ └── Theme.kt
│ ├── navigation/ # Navigation setup
│ │ └── Navigation.kt
│ └── screens/ # Screen composables
│ ├── main/
│ │ ├── MainScreen.kt
│ │ └── MainViewModel.kt
│ └── settings/
│ ├── SettingsScreen.kt
│ └── SettingsViewModel.kt
├── KaRemoteApp.kt # Application class
├── MainActivity.kt # Main activity
└── MainActivityViewModel.kt
```
## Features
### Karadio32 Integration
- HTTP API communication for remote control
- Supported commands: play/stop, volume control, station switching
- Status polling every 2 seconds
- Station list retrieval
### RadioBrowser.info Integration
- Station search by name
- Favicon/icon retrieval
- Genre, bitrate, codec information
### Settings
- Server address configuration (IP or hostname)
- Theme mode: Light / Dark / System
- Color themes: Default (Teal), Blue, Green, Orange, Purple, Red

View File

@ -1,13 +1,15 @@
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 = "cz.bugsy.karemote"
compileSdk {
version = release(36)
}
compileSdk = 36
defaultConfig {
applicationId = "cz.bugsy.karemote"
@ -17,6 +19,9 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
@ -35,13 +40,66 @@ android {
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
// Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Navigation
implementation(libs.androidx.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.retrofit.converter.scalars)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Image loading
implementation(libs.coil.compose)
// DataStore
implementation(libs.androidx.datastore.preferences)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@ -2,14 +2,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".KaRemoteApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KaRemote" />
android:theme="@style/Theme.KaRemote"
tools:targetApi="36">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.KaRemote">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,7 @@
package cz.bugsy.karemote
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class KaRemoteApp : Application()

View File

@ -0,0 +1,40 @@
package cz.bugsy.karemote
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cz.bugsy.karemote.ui.navigation.KaRemoteNavHost
import cz.bugsy.karemote.ui.theme.KaRemoteTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val settingsViewModel: MainActivityViewModel = hiltViewModel()
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
KaRemoteTheme(
themeMode = settings.themeMode,
colorTheme = settings.colorTheme
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
KaRemoteNavHost()
}
}
}
}
}

View File

@ -0,0 +1,24 @@
package cz.bugsy.karemote
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cz.bugsy.karemote.data.model.AppSettings
import cz.bugsy.karemote.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
settingsRepository: SettingsRepository
) : ViewModel() {
val settings: StateFlow<AppSettings> = settingsRepository.settings
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = AppSettings()
)
}

View File

@ -0,0 +1,31 @@
package cz.bugsy.karemote.data.api
import retrofit2.http.GET
import retrofit2.http.Query
interface KaradioApi {
@GET("/")
suspend fun getStatus(@Query("infos") infos: String = ""): String
@GET("/")
suspend fun play(@Query("start") start: String = ""): String
@GET("/")
suspend fun stop(@Query("stop") stop: String = ""): String
@GET("/")
suspend fun setVolume(@Query("volume") volume: Int): String
@GET("/")
suspend fun playStation(@Query("play") stationNumber: Int): String
@GET("/")
suspend fun nextStation(@Query("next") next: String = ""): String
@GET("/")
suspend fun previousStation(@Query("prev") prev: String = ""): String
@GET("/")
suspend fun getStationName(@Query("list") stationNumber: Int): String
}

View File

@ -0,0 +1,25 @@
package cz.bugsy.karemote.data.api
import cz.bugsy.karemote.data.model.RadioBrowserStation
import retrofit2.http.GET
import retrofit2.http.Query
interface RadioBrowserApi {
@GET("json/stations/search")
suspend fun searchStations(
@Query("name") name: String,
@Query("limit") limit: Int = 5,
@Query("hidebroken") hideBroken: Boolean = true
): List<RadioBrowserStation>
@GET("json/stations/byurl")
suspend fun searchByUrl(
@Query("url") url: String,
@Query("limit") limit: Int = 1
): List<RadioBrowserStation>
companion object {
const val BASE_URL = "https://de1.api.radio-browser.info/"
}
}

View File

@ -0,0 +1,20 @@
package cz.bugsy.karemote.data.model
enum class ThemeMode {
LIGHT, DARK, SYSTEM
}
enum class ColorTheme(val displayName: String) {
DEFAULT("Default"),
BLUE("Blue"),
GREEN("Green"),
ORANGE("Orange"),
PURPLE("Purple"),
RED("Red")
}
data class AppSettings(
val serverAddress: String = "",
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val colorTheme: ColorTheme = ColorTheme.DEFAULT
)

View File

@ -0,0 +1,45 @@
package cz.bugsy.karemote.data.model
data class KaradioStatus(
val volume: Int = 0,
val stationNumber: Int = 0,
val stationName: String = "",
val title: String = "",
val isPlaying: Boolean = false
)
data class KaradioStation(
val index: Int,
val name: String,
val url: String,
val path: String,
val port: Int,
val volumeOffset: Int = 0
)
fun parseKaradioStatus(response: String): KaradioStatus {
val lines = response.lines()
var volume = 0
var stationNumber = 0
var stationName = ""
var title = ""
var isPlaying = false
for (line in lines) {
when {
line.startsWith("vol:") -> volume = line.substringAfter("vol:").trim().toIntOrNull() ?: 0
line.startsWith("num:") -> stationNumber = line.substringAfter("num:").trim().toIntOrNull() ?: 0
line.startsWith("stn:") -> stationName = line.substringAfter("stn:").trim()
line.startsWith("tit:") -> title = line.substringAfter("tit:").trim()
line.startsWith("sts:") -> isPlaying = line.substringAfter("sts:").trim() == "1"
}
}
return KaradioStatus(
volume = volume,
stationNumber = stationNumber,
stationName = stationName,
title = title,
isPlaying = isPlaying
)
}

View File

@ -0,0 +1,50 @@
package cz.bugsy.karemote.data.model
import com.google.gson.annotations.SerializedName
data class RadioBrowserStation(
@SerializedName("stationuuid")
val stationUuid: String = "",
@SerializedName("name")
val name: String = "",
@SerializedName("url")
val url: String = "",
@SerializedName("url_resolved")
val urlResolved: String = "",
@SerializedName("homepage")
val homepage: String = "",
@SerializedName("favicon")
val favicon: String = "",
@SerializedName("tags")
val tags: String = "",
@SerializedName("country")
val country: String = "",
@SerializedName("countrycode")
val countryCode: String = "",
@SerializedName("language")
val language: String = "",
@SerializedName("codec")
val codec: String = "",
@SerializedName("bitrate")
val bitrate: Int = 0,
@SerializedName("votes")
val votes: Int = 0,
@SerializedName("clickcount")
val clickCount: Int = 0
) {
val genre: String
get() = tags.split(",").firstOrNull()?.trim() ?: ""
}

View File

@ -0,0 +1,129 @@
package cz.bugsy.karemote.data.repository
import cz.bugsy.karemote.data.api.KaradioApi
import cz.bugsy.karemote.data.model.KaradioStation
import cz.bugsy.karemote.data.model.KaradioStatus
import cz.bugsy.karemote.data.model.parseKaradioStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KaradioRepository @Inject constructor() {
private var karadioApi: KaradioApi? = null
private var currentBaseUrl: String? = null
private fun getApi(serverAddress: String): KaradioApi {
val baseUrl = if (serverAddress.startsWith("http")) {
serverAddress
} else {
"http://$serverAddress"
}.trimEnd('/') + "/"
if (karadioApi == null || currentBaseUrl != baseUrl) {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
karadioApi = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(ScalarsConverterFactory.create())
.build()
.create(KaradioApi::class.java)
currentBaseUrl = baseUrl
}
return karadioApi!!
}
suspend fun getStatus(serverAddress: String): Result<KaradioStatus> = withContext(Dispatchers.IO) {
runCatching {
val response = getApi(serverAddress).getStatus()
parseKaradioStatus(response)
}
}
suspend fun play(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).play()
Unit
}
}
suspend fun stop(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).stop()
Unit
}
}
suspend fun setVolume(serverAddress: String, volume: Int): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).setVolume(volume.coerceIn(0, 254))
Unit
}
}
suspend fun playStation(serverAddress: String, stationNumber: Int): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).playStation(stationNumber)
Unit
}
}
suspend fun nextStation(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).nextStation()
Unit
}
}
suspend fun previousStation(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
getApi(serverAddress).previousStation()
Unit
}
}
suspend fun getStationList(serverAddress: String, maxStations: Int = 255): Result<List<KaradioStation>> =
withContext(Dispatchers.IO) {
runCatching {
val stations = mutableListOf<KaradioStation>()
for (i in 1 until maxStations) {
val response = getApi(serverAddress).getStationName(i)
val name = parseStationNameResponse(response)
if (name.isNotBlank() && name != "not set") {
stations.add(
KaradioStation(
index = i,
name = name,
url = "",
path = "",
port = 80
)
)
}
}
stations
}
}
private fun parseStationNameResponse(response: String): String {
// Response format: "NAME: station name" or similar
return response.lines()
.firstOrNull { it.contains(":") }
?.substringAfter(":")
?.trim()
?: response.trim()
}
}

View File

@ -0,0 +1,40 @@
package cz.bugsy.karemote.data.repository
import cz.bugsy.karemote.data.api.RadioBrowserApi
import cz.bugsy.karemote.data.model.RadioBrowserStation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RadioBrowserRepository @Inject constructor(
private val radioBrowserApi: RadioBrowserApi
) {
private val stationCache = mutableMapOf<String, RadioBrowserStation?>()
suspend fun searchStation(name: String): Result<RadioBrowserStation?> = withContext(Dispatchers.IO) {
// Check cache first
stationCache[name]?.let { return@withContext Result.success(it) }
runCatching {
val stations = radioBrowserApi.searchStations(name = name, limit = 5)
// Find best match - prefer exact name match or highest vote count
val station = stations.firstOrNull { it.name.equals(name, ignoreCase = true) }
?: stations.maxByOrNull { it.votes }
// Cache the result
stationCache[name] = station
station
}
}
suspend fun getStationInfo(stationName: String): RadioBrowserStation? {
return searchStation(stationName).getOrNull()
}
fun clearCache() {
stationCache.clear()
}
}

View File

@ -0,0 +1,59 @@
package cz.bugsy.karemote.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import cz.bugsy.karemote.data.model.AppSettings
import cz.bugsy.karemote.data.model.ColorTheme
import cz.bugsy.karemote.data.model.ThemeMode
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private object PreferencesKeys {
val SERVER_ADDRESS = stringPreferencesKey("server_address")
val THEME_MODE = stringPreferencesKey("theme_mode")
val COLOR_THEME = stringPreferencesKey("color_theme")
}
val settings: Flow<AppSettings> = context.dataStore.data.map { preferences ->
AppSettings(
serverAddress = preferences[PreferencesKeys.SERVER_ADDRESS] ?: "",
themeMode = preferences[PreferencesKeys.THEME_MODE]?.let {
ThemeMode.valueOf(it)
} ?: ThemeMode.SYSTEM,
colorTheme = preferences[PreferencesKeys.COLOR_THEME]?.let {
ColorTheme.valueOf(it)
} ?: ColorTheme.DEFAULT
)
}
suspend fun updateServerAddress(address: String) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SERVER_ADDRESS] = address
}
}
suspend fun updateThemeMode(mode: ThemeMode) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.THEME_MODE] = mode.name
}
}
suspend fun updateColorTheme(theme: ColorTheme) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.COLOR_THEME] = theme.name
}
}
}

View File

@ -0,0 +1,48 @@
package cz.bugsy.karemote.di
import cz.bugsy.karemote.data.api.RadioBrowserApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "KaRemote/1.0")
.build()
chain.proceed(request)
}
.build()
}
@Provides
@Singleton
fun provideRadioBrowserApi(okHttpClient: OkHttpClient): RadioBrowserApi {
return Retrofit.Builder()
.baseUrl(RadioBrowserApi.BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(RadioBrowserApi::class.java)
}
}

View File

@ -0,0 +1,40 @@
package cz.bugsy.karemote.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cz.bugsy.karemote.ui.screens.main.MainScreen
import cz.bugsy.karemote.ui.screens.settings.SettingsScreen
sealed class Screen(val route: String) {
data object Main : Screen("main")
data object Settings : Screen("settings")
}
@Composable
fun KaRemoteNavHost(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = Screen.Main.route
) {
composable(Screen.Main.route) {
MainScreen(
onNavigateToSettings = {
navController.navigate(Screen.Settings.route)
}
)
}
composable(Screen.Settings.route) {
SettingsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}

View File

@ -0,0 +1,512 @@
package cz.bugsy.karemote.ui.screens.main
import androidx.compose.foundation.background
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.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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
onNavigateToSettings: () -> Unit,
viewModel: MainViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("KaRemote") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings"
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (uiState.serverAddress.isBlank()) {
NoServerConfigured(onNavigateToSettings)
} else if (!uiState.isConnected) {
ConnectionError(
errorMessage = uiState.errorMessage,
onRetry = { viewModel.refreshStatus() }
)
} else {
PlayerContent(
uiState = uiState,
onTogglePlayPause = viewModel::togglePlayPause,
onVolumeChange = viewModel::setVolume,
onNextStation = viewModel::nextStation,
onPreviousStation = viewModel::previousStation,
onShowStationPicker = viewModel::showStationPicker
)
}
}
// Station picker bottom sheet
if (uiState.showStationPicker) {
StationPickerSheet(
stations = uiState.stations,
isLoading = uiState.isLoadingStations,
currentStation = uiState.karadioStatus.stationNumber,
onStationSelected = viewModel::playStation,
onDismiss = viewModel::hideStationPicker
)
}
}
}
@Composable
private fun NoServerConfigured(onNavigateToSettings: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No Server Configured",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Please configure your Karadio32 server address in settings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
FilledIconButton(
onClick = onNavigateToSettings,
modifier = Modifier.size(56.dp)
) {
Icon(Icons.Default.Settings, contentDescription = "Go to Settings")
}
}
}
@Composable
private fun ConnectionError(
errorMessage: String?,
onRetry: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connection Error",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(24.dp))
FilledIconButton(
onClick = onRetry,
modifier = Modifier.size(56.dp)
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Retry")
}
}
}
@Composable
private fun PlayerContent(
uiState: MainUiState,
onTogglePlayPause: () -> Unit,
onVolumeChange: (Int) -> Unit,
onNextStation: () -> Unit,
onPreviousStation: () -> Unit,
onShowStationPicker: () -> Unit
) {
val status = uiState.karadioStatus
val radioBrowserInfo = uiState.radioBrowserInfo
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Station artwork
Card(
modifier = Modifier
.size(200.dp)
.clickable { onShowStationPicker() },
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (!radioBrowserInfo?.favicon.isNullOrBlank()) {
AsyncImage(
model = radioBrowserInfo?.favicon,
contentDescription = "Station logo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Radio,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Station name
Text(
text = status.stationName.ifBlank { "No Station" },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.clickable { onShowStationPicker() }
)
// Genre
if (radioBrowserInfo?.genre?.isNotBlank() == true) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = radioBrowserInfo.genre,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
// Current track/metadata
Text(
text = status.title.ifBlank { "No metadata available" },
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
// Station info
if (radioBrowserInfo != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (radioBrowserInfo.bitrate > 0) {
Text(
text = "${radioBrowserInfo.bitrate} kbps",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
if (radioBrowserInfo.codec.isNotBlank()) {
if (radioBrowserInfo.bitrate > 0) {
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Text(
text = radioBrowserInfo.codec,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Playback controls
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
FilledIconButton(
onClick = onPreviousStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous station",
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onTogglePlayPause,
modifier = Modifier.size(72.dp)
) {
Icon(
imageVector = if (status.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (status.isPlaying) "Pause" else "Play",
modifier = Modifier.size(40.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = onNextStation,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "Next station",
modifier = Modifier.size(32.dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Volume control
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Volume",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${(status.volume * 100 / 254)}%",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = status.volume.toFloat(),
onValueChange = { onVolumeChange(it.toInt()) },
valueRange = 0f..254f,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StationPickerSheet(
stations: List<cz.bugsy.karemote.data.model.KaradioStation>,
isLoading: Boolean,
currentStation: Int,
onStationSelected: (Int) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text(
text = "Select Station",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (stations.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No stations found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn {
items(stations) { station ->
ListItem(
headlineContent = { Text(station.name) },
supportingContent = { Text("Station ${station.index}") },
leadingContent = {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (station.index == currentStation)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primaryContainer
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Radio,
contentDescription = null,
tint = if (station.index == currentStation)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp)
)
}
},
modifier = Modifier.clickable { onStationSelected(station.index) }
)
}
}
}
}
}
}

View File

@ -0,0 +1,223 @@
package cz.bugsy.karemote.ui.screens.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cz.bugsy.karemote.data.model.KaradioStation
import cz.bugsy.karemote.data.model.KaradioStatus
import cz.bugsy.karemote.data.model.RadioBrowserStation
import cz.bugsy.karemote.data.repository.KaradioRepository
import cz.bugsy.karemote.data.repository.RadioBrowserRepository
import cz.bugsy.karemote.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MainUiState(
val isLoading: Boolean = false,
val isConnected: Boolean = false,
val errorMessage: String? = null,
val serverAddress: String = "",
val karadioStatus: KaradioStatus = KaradioStatus(),
val radioBrowserInfo: RadioBrowserStation? = null,
val stations: List<KaradioStation> = emptyList(),
val isLoadingStations: Boolean = false,
val showStationPicker: Boolean = false
)
@HiltViewModel
class MainViewModel @Inject constructor(
private val karadioRepository: KaradioRepository,
private val radioBrowserRepository: RadioBrowserRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
private var pollingJob: Job? = null
private var lastStationName: String? = null
init {
viewModelScope.launch {
settingsRepository.settings.collect { settings ->
_uiState.update { it.copy(serverAddress = settings.serverAddress) }
if (settings.serverAddress.isNotBlank()) {
startPolling()
} else {
stopPolling()
_uiState.update { it.copy(isConnected = false) }
}
}
}
}
private fun startPolling() {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
while (true) {
refreshStatus()
delay(2000) // Poll every 2 seconds
}
}
}
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
fun refreshStatus() {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
karadioRepository.getStatus(serverAddress)
.onSuccess { status ->
_uiState.update {
it.copy(
isConnected = true,
errorMessage = null,
karadioStatus = status
)
}
// Fetch station info from RadioBrowser if station name changed
if (status.stationName != lastStationName && status.stationName.isNotBlank()) {
lastStationName = status.stationName
fetchRadioBrowserInfo(status.stationName)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isConnected = false,
errorMessage = "Connection failed: ${error.localizedMessage}"
)
}
}
}
}
private fun fetchRadioBrowserInfo(stationName: String) {
viewModelScope.launch {
radioBrowserRepository.searchStation(stationName)
.onSuccess { station ->
_uiState.update { it.copy(radioBrowserInfo = station) }
}
.onFailure {
_uiState.update { it.copy(radioBrowserInfo = null) }
}
}
}
fun togglePlayPause() {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
val isPlaying = _uiState.value.karadioStatus.isPlaying
if (isPlaying) {
karadioRepository.stop(serverAddress)
} else {
karadioRepository.play(serverAddress)
}
delay(300)
refreshStatus()
}
}
fun setVolume(volume: Int) {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
// Optimistically update UI
_uiState.update {
it.copy(karadioStatus = it.karadioStatus.copy(volume = volume))
}
karadioRepository.setVolume(serverAddress, volume)
}
}
fun nextStation() {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
karadioRepository.nextStation(serverAddress)
delay(500)
refreshStatus()
}
}
fun previousStation() {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
karadioRepository.previousStation(serverAddress)
delay(500)
refreshStatus()
}
}
fun playStation(stationNumber: Int) {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) return@launch
karadioRepository.playStation(serverAddress, stationNumber)
_uiState.update { it.copy(showStationPicker = false) }
delay(500)
refreshStatus()
}
}
fun showStationPicker() {
viewModelScope.launch {
_uiState.update { it.copy(showStationPicker = true, isLoadingStations = true) }
loadStations()
}
}
fun hideStationPicker() {
_uiState.update { it.copy(showStationPicker = false) }
}
private fun loadStations() {
viewModelScope.launch {
val serverAddress = _uiState.value.serverAddress
if (serverAddress.isBlank()) {
_uiState.update { it.copy(isLoadingStations = false) }
return@launch
}
karadioRepository.getStationList(serverAddress, maxStations = 50)
.onSuccess { stations ->
_uiState.update {
it.copy(stations = stations, isLoadingStations = false)
}
}
.onFailure {
_uiState.update { it.copy(isLoadingStations = false) }
}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
override fun onCleared() {
super.onCleared()
stopPolling()
}
}

View File

@ -0,0 +1,312 @@
package cz.bugsy.karemote.ui.screens.settings
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.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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cz.bugsy.karemote.data.model.ColorTheme
import cz.bugsy.karemote.data.model.ThemeMode
import cz.bugsy.karemote.ui.theme.BluePrimaryLight
import cz.bugsy.karemote.ui.theme.DefaultPrimaryLight
import cz.bugsy.karemote.ui.theme.GreenPrimaryLight
import cz.bugsy.karemote.ui.theme.OrangePrimaryLight
import cz.bugsy.karemote.ui.theme.PurplePrimaryLight
import cz.bugsy.karemote.ui.theme.RedPrimaryLight
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Server Settings
SettingsSection(
title = "Connection",
icon = Icons.Default.Wifi
) {
OutlinedTextField(
value = uiState.serverAddressInput,
onValueChange = viewModel::updateServerAddressInput,
label = { Text("Karadio32 Server Address") },
placeholder = { Text("192.168.1.100 or karadio.local") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = viewModel::saveServerAddress,
enabled = !uiState.isSaving && uiState.serverAddressInput != uiState.settings.serverAddress,
modifier = Modifier.fillMaxWidth()
) {
Text(if (uiState.isSaving) "Saving..." else "Save")
}
}
Spacer(modifier = Modifier.height(24.dp))
// Theme Mode
SettingsSection(
title = "Theme Mode",
icon = Icons.Default.DarkMode
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ThemeMode.entries.forEach { mode ->
FilterChip(
selected = uiState.settings.themeMode == mode,
onClick = { viewModel.updateThemeMode(mode) },
label = { Text(mode.displayName) },
leadingIcon = {
Icon(
imageVector = mode.icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Color Theme
SettingsSection(
title = "Color Theme",
icon = Icons.Default.Palette
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ColorTheme.entries.forEach { theme ->
ColorThemeOption(
theme = theme,
isSelected = uiState.settings.colorTheme == theme,
onClick = { viewModel.updateColorTheme(theme) }
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// App Info
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "KaRemote",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version 1.0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Remote control for Karadio32",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun SettingsSection(
title: String,
icon: ImageVector,
content: @Composable () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
content()
}
}
}
@Composable
private fun ColorThemeOption(
theme: ColorTheme,
isSelected: Boolean,
onClick: () -> Unit
) {
val color = when (theme) {
ColorTheme.DEFAULT -> DefaultPrimaryLight
ColorTheme.BLUE -> BluePrimaryLight
ColorTheme.GREEN -> GreenPrimaryLight
ColorTheme.ORANGE -> OrangePrimaryLight
ColorTheme.PURPLE -> PurplePrimaryLight
ColorTheme.RED -> RedPrimaryLight
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { onClick() }
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(color)
.then(
if (isSelected) {
Modifier.border(3.dp, MaterialTheme.colorScheme.outline, CircleShape)
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = theme.displayName,
style = MaterialTheme.typography.labelSmall
)
}
}
private val ThemeMode.displayName: String
get() = when (this) {
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
ThemeMode.SYSTEM -> "System"
}
private val ThemeMode.icon: ImageVector
get() = when (this) {
ThemeMode.LIGHT -> Icons.Default.LightMode
ThemeMode.DARK -> Icons.Default.DarkMode
ThemeMode.SYSTEM -> Icons.Default.PhoneAndroid
}

View File

@ -0,0 +1,67 @@
package cz.bugsy.karemote.ui.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cz.bugsy.karemote.data.model.AppSettings
import cz.bugsy.karemote.data.model.ColorTheme
import cz.bugsy.karemote.data.model.ThemeMode
import cz.bugsy.karemote.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val settings: AppSettings = AppSettings(),
val serverAddressInput: String = "",
val isSaving: Boolean = false
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.settings.collect { settings ->
_uiState.update {
it.copy(
settings = settings,
serverAddressInput = settings.serverAddress
)
}
}
}
}
fun updateServerAddressInput(address: String) {
_uiState.update { it.copy(serverAddressInput = address) }
}
fun saveServerAddress() {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
settingsRepository.updateServerAddress(_uiState.value.serverAddressInput)
_uiState.update { it.copy(isSaving = false) }
}
}
fun updateThemeMode(mode: ThemeMode) {
viewModelScope.launch {
settingsRepository.updateThemeMode(mode)
}
}
fun updateColorTheme(theme: ColorTheme) {
viewModelScope.launch {
settingsRepository.updateColorTheme(theme)
}
}
}

View File

@ -0,0 +1,154 @@
package cz.bugsy.karemote.ui.theme
import androidx.compose.ui.graphics.Color
// Default Theme Colors (Teal)
val DefaultPrimaryLight = Color(0xFF006A6A)
val DefaultOnPrimaryLight = Color(0xFFFFFFFF)
val DefaultPrimaryContainerLight = Color(0xFF9CF1F0)
val DefaultOnPrimaryContainerLight = Color(0xFF002020)
val DefaultSecondaryLight = Color(0xFF4A6363)
val DefaultOnSecondaryLight = Color(0xFFFFFFFF)
val DefaultSecondaryContainerLight = Color(0xFFCCE8E7)
val DefaultOnSecondaryContainerLight = Color(0xFF051F1F)
val DefaultTertiaryLight = Color(0xFF4B607C)
val DefaultOnTertiaryLight = Color(0xFFFFFFFF)
val DefaultTertiaryContainerLight = Color(0xFFD3E4FF)
val DefaultOnTertiaryContainerLight = Color(0xFF041C35)
val DefaultPrimaryDark = Color(0xFF80D5D4)
val DefaultOnPrimaryDark = Color(0xFF003737)
val DefaultPrimaryContainerDark = Color(0xFF004F4F)
val DefaultOnPrimaryContainerDark = Color(0xFF9CF1F0)
val DefaultSecondaryDark = Color(0xFFB0CCCB)
val DefaultOnSecondaryDark = Color(0xFF1B3434)
val DefaultSecondaryContainerDark = Color(0xFF324B4B)
val DefaultOnSecondaryContainerDark = Color(0xFFCCE8E7)
val DefaultTertiaryDark = Color(0xFFB3C8E8)
val DefaultOnTertiaryDark = Color(0xFF1C314B)
val DefaultTertiaryContainerDark = Color(0xFF334863)
val DefaultOnTertiaryContainerDark = Color(0xFFD3E4FF)
// Blue Theme
val BluePrimaryLight = Color(0xFF0061A4)
val BlueOnPrimaryLight = Color(0xFFFFFFFF)
val BluePrimaryContainerLight = Color(0xFFD1E4FF)
val BlueOnPrimaryContainerLight = Color(0xFF001D36)
val BlueSecondaryLight = Color(0xFF535F70)
val BlueOnSecondaryLight = Color(0xFFFFFFFF)
val BlueSecondaryContainerLight = Color(0xFFD7E3F8)
val BlueOnSecondaryContainerLight = Color(0xFF101C2B)
val BluePrimaryDark = Color(0xFF9FCAFF)
val BlueOnPrimaryDark = Color(0xFF003258)
val BluePrimaryContainerDark = Color(0xFF00497D)
val BlueOnPrimaryContainerDark = Color(0xFFD1E4FF)
val BlueSecondaryDark = Color(0xFFBBC7DB)
val BlueOnSecondaryDark = Color(0xFF253140)
val BlueSecondaryContainerDark = Color(0xFF3B4858)
val BlueOnSecondaryContainerDark = Color(0xFFD7E3F8)
// Green Theme
val GreenPrimaryLight = Color(0xFF006E1C)
val GreenOnPrimaryLight = Color(0xFFFFFFFF)
val GreenPrimaryContainerLight = Color(0xFF94F990)
val GreenOnPrimaryContainerLight = Color(0xFF002204)
val GreenSecondaryLight = Color(0xFF52634F)
val GreenOnSecondaryLight = Color(0xFFFFFFFF)
val GreenSecondaryContainerLight = Color(0xFFD5E8CF)
val GreenOnSecondaryContainerLight = Color(0xFF101F10)
val GreenPrimaryDark = Color(0xFF78DC77)
val GreenOnPrimaryDark = Color(0xFF00390A)
val GreenPrimaryContainerDark = Color(0xFF005313)
val GreenOnPrimaryContainerDark = Color(0xFF94F990)
val GreenSecondaryDark = Color(0xFFB9CCB4)
val GreenOnSecondaryDark = Color(0xFF253423)
val GreenSecondaryContainerDark = Color(0xFF3B4B38)
val GreenOnSecondaryContainerDark = Color(0xFFD5E8CF)
// Orange Theme
val OrangePrimaryLight = Color(0xFF8B5000)
val OrangeOnPrimaryLight = Color(0xFFFFFFFF)
val OrangePrimaryContainerLight = Color(0xFFFFDCBE)
val OrangeOnPrimaryContainerLight = Color(0xFF2C1600)
val OrangeSecondaryLight = Color(0xFF735944)
val OrangeOnSecondaryLight = Color(0xFFFFFFFF)
val OrangeSecondaryContainerLight = Color(0xFFFFDCBE)
val OrangeOnSecondaryContainerLight = Color(0xFF291806)
val OrangePrimaryDark = Color(0xFFFFB871)
val OrangeOnPrimaryDark = Color(0xFF4A2800)
val OrangePrimaryContainerDark = Color(0xFF693C00)
val OrangeOnPrimaryContainerDark = Color(0xFFFFDCBE)
val OrangeSecondaryDark = Color(0xFFE1C0A8)
val OrangeOnSecondaryDark = Color(0xFF3F2D1A)
val OrangeSecondaryContainerDark = Color(0xFF59422E)
val OrangeOnSecondaryContainerDark = Color(0xFFFFDCBE)
// Purple Theme
val PurplePrimaryLight = Color(0xFF6750A4)
val PurpleOnPrimaryLight = Color(0xFFFFFFFF)
val PurplePrimaryContainerLight = Color(0xFFEADDFF)
val PurpleOnPrimaryContainerLight = Color(0xFF21005E)
val PurpleSecondaryLight = Color(0xFF625B71)
val PurpleOnSecondaryLight = Color(0xFFFFFFFF)
val PurpleSecondaryContainerLight = Color(0xFFE8DEF8)
val PurpleOnSecondaryContainerLight = Color(0xFF1E192B)
val PurplePrimaryDark = Color(0xFFD0BCFF)
val PurpleOnPrimaryDark = Color(0xFF381E72)
val PurplePrimaryContainerDark = Color(0xFF4F378B)
val PurpleOnPrimaryContainerDark = Color(0xFFEADDFF)
val PurpleSecondaryDark = Color(0xFFCCC2DC)
val PurpleOnSecondaryDark = Color(0xFF332D41)
val PurpleSecondaryContainerDark = Color(0xFF4A4458)
val PurpleOnSecondaryContainerDark = Color(0xFFE8DEF8)
// Red Theme
val RedPrimaryLight = Color(0xFFB3261E)
val RedOnPrimaryLight = Color(0xFFFFFFFF)
val RedPrimaryContainerLight = Color(0xFFF9DEDC)
val RedOnPrimaryContainerLight = Color(0xFF410E0B)
val RedSecondaryLight = Color(0xFF77574F)
val RedOnSecondaryLight = Color(0xFFFFFFFF)
val RedSecondaryContainerLight = Color(0xFFFFDAD4)
val RedOnSecondaryContainerLight = Color(0xFF2C1510)
val RedPrimaryDark = Color(0xFFF2B8B5)
val RedOnPrimaryDark = Color(0xFF601410)
val RedPrimaryContainerDark = Color(0xFF8C1D18)
val RedOnPrimaryContainerDark = Color(0xFFF9DEDC)
val RedSecondaryDark = Color(0xFFE7BDB6)
val RedOnSecondaryDark = Color(0xFF442925)
val RedSecondaryContainerDark = Color(0xFF5D3F3A)
val RedOnSecondaryContainerDark = Color(0xFFFFDAD4)
// Common Colors
val BackgroundLight = Color(0xFFFAFDFD)
val OnBackgroundLight = Color(0xFF191C1C)
val SurfaceLight = Color(0xFFFAFDFD)
val OnSurfaceLight = Color(0xFF191C1C)
val SurfaceVariantLight = Color(0xFFDAE5E4)
val OnSurfaceVariantLight = Color(0xFF3F4948)
val OutlineLight = Color(0xFF6F7979)
val OutlineVariantLight = Color(0xFFBEC9C8)
val BackgroundDark = Color(0xFF191C1C)
val OnBackgroundDark = Color(0xFFE0E3E3)
val SurfaceDark = Color(0xFF191C1C)
val OnSurfaceDark = Color(0xFFE0E3E3)
val SurfaceVariantDark = Color(0xFF3F4948)
val OnSurfaceVariantDark = Color(0xFFBEC9C8)
val OutlineDark = Color(0xFF889392)
val OutlineVariantDark = Color(0xFF3F4948)
val ErrorLight = Color(0xFFBA1A1A)
val OnErrorLight = Color(0xFFFFFFFF)
val ErrorContainerLight = Color(0xFFFFDAD6)
val OnErrorContainerLight = Color(0xFF410002)
val ErrorDark = Color(0xFFFFB4AB)
val OnErrorDark = Color(0xFF690005)
val ErrorContainerDark = Color(0xFF93000A)
val OnErrorContainerDark = Color(0xFFFFDAD6)

View File

@ -0,0 +1,270 @@
package cz.bugsy.karemote.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.ui.platform.LocalContext
import cz.bugsy.karemote.data.model.ColorTheme
import cz.bugsy.karemote.data.model.ThemeMode
// Default Color Schemes
private val DefaultLightColorScheme = lightColorScheme(
primary = DefaultPrimaryLight,
onPrimary = DefaultOnPrimaryLight,
primaryContainer = DefaultPrimaryContainerLight,
onPrimaryContainer = DefaultOnPrimaryContainerLight,
secondary = DefaultSecondaryLight,
onSecondary = DefaultOnSecondaryLight,
secondaryContainer = DefaultSecondaryContainerLight,
onSecondaryContainer = DefaultOnSecondaryContainerLight,
tertiary = DefaultTertiaryLight,
onTertiary = DefaultOnTertiaryLight,
tertiaryContainer = DefaultTertiaryContainerLight,
onTertiaryContainer = DefaultOnTertiaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceVariantLight,
outline = OutlineLight,
outlineVariant = OutlineVariantLight,
error = ErrorLight,
onError = OnErrorLight,
errorContainer = ErrorContainerLight,
onErrorContainer = OnErrorContainerLight
)
private val DefaultDarkColorScheme = darkColorScheme(
primary = DefaultPrimaryDark,
onPrimary = DefaultOnPrimaryDark,
primaryContainer = DefaultPrimaryContainerDark,
onPrimaryContainer = DefaultOnPrimaryContainerDark,
secondary = DefaultSecondaryDark,
onSecondary = DefaultOnSecondaryDark,
secondaryContainer = DefaultSecondaryContainerDark,
onSecondaryContainer = DefaultOnSecondaryContainerDark,
tertiary = DefaultTertiaryDark,
onTertiary = DefaultOnTertiaryDark,
tertiaryContainer = DefaultTertiaryContainerDark,
onTertiaryContainer = DefaultOnTertiaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark,
error = ErrorDark,
onError = OnErrorDark,
errorContainer = ErrorContainerDark,
onErrorContainer = OnErrorContainerDark
)
// Blue Color Schemes
private val BlueLightColorScheme = lightColorScheme(
primary = BluePrimaryLight,
onPrimary = BlueOnPrimaryLight,
primaryContainer = BluePrimaryContainerLight,
onPrimaryContainer = BlueOnPrimaryContainerLight,
secondary = BlueSecondaryLight,
onSecondary = BlueOnSecondaryLight,
secondaryContainer = BlueSecondaryContainerLight,
onSecondaryContainer = BlueOnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
error = ErrorLight,
onError = OnErrorLight
)
private val BlueDarkColorScheme = darkColorScheme(
primary = BluePrimaryDark,
onPrimary = BlueOnPrimaryDark,
primaryContainer = BluePrimaryContainerDark,
onPrimaryContainer = BlueOnPrimaryContainerDark,
secondary = BlueSecondaryDark,
onSecondary = BlueOnSecondaryDark,
secondaryContainer = BlueSecondaryContainerDark,
onSecondaryContainer = BlueOnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorDark,
onError = OnErrorDark
)
// Green Color Schemes
private val GreenLightColorScheme = lightColorScheme(
primary = GreenPrimaryLight,
onPrimary = GreenOnPrimaryLight,
primaryContainer = GreenPrimaryContainerLight,
onPrimaryContainer = GreenOnPrimaryContainerLight,
secondary = GreenSecondaryLight,
onSecondary = GreenOnSecondaryLight,
secondaryContainer = GreenSecondaryContainerLight,
onSecondaryContainer = GreenOnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
error = ErrorLight,
onError = OnErrorLight
)
private val GreenDarkColorScheme = darkColorScheme(
primary = GreenPrimaryDark,
onPrimary = GreenOnPrimaryDark,
primaryContainer = GreenPrimaryContainerDark,
onPrimaryContainer = GreenOnPrimaryContainerDark,
secondary = GreenSecondaryDark,
onSecondary = GreenOnSecondaryDark,
secondaryContainer = GreenSecondaryContainerDark,
onSecondaryContainer = GreenOnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorDark,
onError = OnErrorDark
)
// Orange Color Schemes
private val OrangeLightColorScheme = lightColorScheme(
primary = OrangePrimaryLight,
onPrimary = OrangeOnPrimaryLight,
primaryContainer = OrangePrimaryContainerLight,
onPrimaryContainer = OrangeOnPrimaryContainerLight,
secondary = OrangeSecondaryLight,
onSecondary = OrangeOnSecondaryLight,
secondaryContainer = OrangeSecondaryContainerLight,
onSecondaryContainer = OrangeOnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
error = ErrorLight,
onError = OnErrorLight
)
private val OrangeDarkColorScheme = darkColorScheme(
primary = OrangePrimaryDark,
onPrimary = OrangeOnPrimaryDark,
primaryContainer = OrangePrimaryContainerDark,
onPrimaryContainer = OrangeOnPrimaryContainerDark,
secondary = OrangeSecondaryDark,
onSecondary = OrangeOnSecondaryDark,
secondaryContainer = OrangeSecondaryContainerDark,
onSecondaryContainer = OrangeOnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorDark,
onError = OnErrorDark
)
// Purple Color Schemes
private val PurpleLightColorScheme = lightColorScheme(
primary = PurplePrimaryLight,
onPrimary = PurpleOnPrimaryLight,
primaryContainer = PurplePrimaryContainerLight,
onPrimaryContainer = PurpleOnPrimaryContainerLight,
secondary = PurpleSecondaryLight,
onSecondary = PurpleOnSecondaryLight,
secondaryContainer = PurpleSecondaryContainerLight,
onSecondaryContainer = PurpleOnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
error = ErrorLight,
onError = OnErrorLight
)
private val PurpleDarkColorScheme = darkColorScheme(
primary = PurplePrimaryDark,
onPrimary = PurpleOnPrimaryDark,
primaryContainer = PurplePrimaryContainerDark,
onPrimaryContainer = PurpleOnPrimaryContainerDark,
secondary = PurpleSecondaryDark,
onSecondary = PurpleOnSecondaryDark,
secondaryContainer = PurpleSecondaryContainerDark,
onSecondaryContainer = PurpleOnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorDark,
onError = OnErrorDark
)
// Red Color Schemes
private val RedLightColorScheme = lightColorScheme(
primary = RedPrimaryLight,
onPrimary = RedOnPrimaryLight,
primaryContainer = RedPrimaryContainerLight,
onPrimaryContainer = RedOnPrimaryContainerLight,
secondary = RedSecondaryLight,
onSecondary = RedOnSecondaryLight,
secondaryContainer = RedSecondaryContainerLight,
onSecondaryContainer = RedOnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
error = ErrorLight,
onError = OnErrorLight
)
private val RedDarkColorScheme = darkColorScheme(
primary = RedPrimaryDark,
onPrimary = RedOnPrimaryDark,
primaryContainer = RedPrimaryContainerDark,
onPrimaryContainer = RedOnPrimaryContainerDark,
secondary = RedSecondaryDark,
onSecondary = RedOnSecondaryDark,
secondaryContainer = RedSecondaryContainerDark,
onSecondaryContainer = RedOnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
error = ErrorDark,
onError = OnErrorDark
)
@Composable
fun KaRemoteTheme(
themeMode: ThemeMode = ThemeMode.SYSTEM,
colorTheme: ColorTheme = ColorTheme.DEFAULT,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
val colorScheme = when (colorTheme) {
ColorTheme.DEFAULT -> if (darkTheme) DefaultDarkColorScheme else DefaultLightColorScheme
ColorTheme.BLUE -> if (darkTheme) BlueDarkColorScheme else BlueLightColorScheme
ColorTheme.GREEN -> if (darkTheme) GreenDarkColorScheme else GreenLightColorScheme
ColorTheme.ORANGE -> if (darkTheme) OrangeDarkColorScheme else OrangeLightColorScheme
ColorTheme.PURPLE -> if (darkTheme) PurpleDarkColorScheme else PurpleLightColorScheme
ColorTheme.RED -> if (darkTheme) RedDarkColorScheme else RedLightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@ -1,16 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KaRemote" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -1,16 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KaRemote" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext traffic to local network devices (Karadio32) -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@ -2,4 +2,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

View File

@ -1,21 +1,79 @@
[versions]
agp = "8.13.1"
kotlin = "2.0.21"
coreKtx = "1.10.1"
ksp = "2.0.21-1.0.28"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
hilt = "2.53.1"
hiltNavigationCompose = "1.2.0"
retrofit = "2.11.0"
okhttp = "4.12.0"
coil = "2.7.0"
datastore = "1.1.1"
kotlinxSerializationJson = "1.7.3"
coroutines = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Lifecycle
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
# Compose
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Networking
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
retrofit-converter-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
# Image loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# DataStore
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }