diff --git a/.gitignore b/.gitignore
index aa724b7..c289c09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 07745bb..b85193b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 340a2b4..3089a11 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 07f6b3b..20a24fd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,14 +2,31 @@
+
+
+
+ android:theme="@style/Theme.KaRemote"
+ tools:targetApi="36">
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt b/app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt
new file mode 100644
index 0000000..ff6eb6b
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt
@@ -0,0 +1,7 @@
+package cz.bugsy.karemote
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class KaRemoteApp : Application()
diff --git a/app/src/main/java/cz/bugsy/karemote/MainActivity.kt b/app/src/main/java/cz/bugsy/karemote/MainActivity.kt
new file mode 100644
index 0000000..313aec9
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt b/app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt
new file mode 100644
index 0000000..1a44074
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt
@@ -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 = settingsRepository.settings
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = AppSettings()
+ )
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt b/app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt
new file mode 100644
index 0000000..6e19c37
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt
@@ -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
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/api/RadioBrowserApi.kt b/app/src/main/java/cz/bugsy/karemote/data/api/RadioBrowserApi.kt
new file mode 100644
index 0000000..f16dd65
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/api/RadioBrowserApi.kt
@@ -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
+
+ @GET("json/stations/byurl")
+ suspend fun searchByUrl(
+ @Query("url") url: String,
+ @Query("limit") limit: Int = 1
+ ): List
+
+ companion object {
+ const val BASE_URL = "https://de1.api.radio-browser.info/"
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/model/AppSettings.kt b/app/src/main/java/cz/bugsy/karemote/data/model/AppSettings.kt
new file mode 100644
index 0000000..c5d5007
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/model/AppSettings.kt
@@ -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
+)
diff --git a/app/src/main/java/cz/bugsy/karemote/data/model/KaradioModels.kt b/app/src/main/java/cz/bugsy/karemote/data/model/KaradioModels.kt
new file mode 100644
index 0000000..9eb3f52
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/model/KaradioModels.kt
@@ -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
+ )
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/model/RadioBrowserModels.kt b/app/src/main/java/cz/bugsy/karemote/data/model/RadioBrowserModels.kt
new file mode 100644
index 0000000..84567e6
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/model/RadioBrowserModels.kt
@@ -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() ?: ""
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/repository/KaradioRepository.kt b/app/src/main/java/cz/bugsy/karemote/data/repository/KaradioRepository.kt
new file mode 100644
index 0000000..033b59c
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/repository/KaradioRepository.kt
@@ -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 = withContext(Dispatchers.IO) {
+ runCatching {
+ val response = getApi(serverAddress).getStatus()
+ parseKaradioStatus(response)
+ }
+ }
+
+ suspend fun play(serverAddress: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).play()
+ Unit
+ }
+ }
+
+ suspend fun stop(serverAddress: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).stop()
+ Unit
+ }
+ }
+
+ suspend fun setVolume(serverAddress: String, volume: Int): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).setVolume(volume.coerceIn(0, 254))
+ Unit
+ }
+ }
+
+ suspend fun playStation(serverAddress: String, stationNumber: Int): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).playStation(stationNumber)
+ Unit
+ }
+ }
+
+ suspend fun nextStation(serverAddress: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).nextStation()
+ Unit
+ }
+ }
+
+ suspend fun previousStation(serverAddress: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ getApi(serverAddress).previousStation()
+ Unit
+ }
+ }
+
+ suspend fun getStationList(serverAddress: String, maxStations: Int = 255): Result> =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val stations = mutableListOf()
+ 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()
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/repository/RadioBrowserRepository.kt b/app/src/main/java/cz/bugsy/karemote/data/repository/RadioBrowserRepository.kt
new file mode 100644
index 0000000..e83df51
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/repository/RadioBrowserRepository.kt
@@ -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()
+
+ suspend fun searchStation(name: String): Result = 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()
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/data/repository/SettingsRepository.kt b/app/src/main/java/cz/bugsy/karemote/data/repository/SettingsRepository.kt
new file mode 100644
index 0000000..2951499
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/data/repository/SettingsRepository.kt
@@ -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 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 = 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
+ }
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt b/app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt
new file mode 100644
index 0000000..83776d2
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/navigation/Navigation.kt b/app/src/main/java/cz/bugsy/karemote/ui/navigation/Navigation.kt
new file mode 100644
index 0000000..735d938
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/navigation/Navigation.kt
@@ -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()
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt
new file mode 100644
index 0000000..9eb60cb
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt
@@ -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,
+ 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) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainViewModel.kt b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainViewModel.kt
new file mode 100644
index 0000000..4bb3ed3
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainViewModel.kt
@@ -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 = 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 = _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()
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsScreen.kt
new file mode 100644
index 0000000..ff07e2d
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsScreen.kt
@@ -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
+ }
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..32ee659
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsViewModel.kt
@@ -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 = _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)
+ }
+ }
+}
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt b/app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt
new file mode 100644
index 0000000..ea38b2c
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt
@@ -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)
diff --git a/app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt b/app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt
new file mode 100644
index 0000000..8011e5c
--- /dev/null
+++ b/app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt
@@ -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
+ )
+}
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 2afab40..f5619e9 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,8 @@
-
-
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 9d5b213..0147a10 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,16 +1,8 @@
-
-
-
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..bb7a705
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 7f09f7c..6944400 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -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
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 524423c..72efb1f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }