From f3a934ba745565647c1a1c2b09150133fdbb4f45 Mon Sep 17 00:00:00 2001 From: vesp Date: Fri, 21 Nov 2025 15:10:19 +0100 Subject: [PATCH] Implement KaRemote Android app for Karadio32 control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 8 +- CLAUDE.md | 70 ++- app/build.gradle.kts | 68 ++- app/src/main/AndroidManifest.xml | 19 +- .../java/cz/bugsy/karemote/KaRemoteApp.kt | 7 + .../java/cz/bugsy/karemote/MainActivity.kt | 40 ++ .../bugsy/karemote/MainActivityViewModel.kt | 24 + .../cz/bugsy/karemote/data/api/KaradioApi.kt | 31 ++ .../karemote/data/api/RadioBrowserApi.kt | 25 + .../bugsy/karemote/data/model/AppSettings.kt | 20 + .../karemote/data/model/KaradioModels.kt | 45 ++ .../karemote/data/model/RadioBrowserModels.kt | 50 ++ .../data/repository/KaradioRepository.kt | 129 +++++ .../data/repository/RadioBrowserRepository.kt | 40 ++ .../data/repository/SettingsRepository.kt | 59 ++ .../cz/bugsy/karemote/di/NetworkModule.kt | 48 ++ .../karemote/ui/navigation/Navigation.kt | 40 ++ .../karemote/ui/screens/main/MainScreen.kt | 512 ++++++++++++++++++ .../karemote/ui/screens/main/MainViewModel.kt | 223 ++++++++ .../ui/screens/settings/SettingsScreen.kt | 312 +++++++++++ .../ui/screens/settings/SettingsViewModel.kt | 67 +++ .../java/cz/bugsy/karemote/ui/theme/Color.kt | 154 ++++++ .../java/cz/bugsy/karemote/ui/theme/Theme.kt | 270 +++++++++ app/src/main/res/values-night/themes.xml | 20 +- app/src/main/res/values/themes.xml | 20 +- .../main/res/xml/network_security_config.xml | 9 + build.gradle.kts | 4 + gradle/libs.versions.toml | 72 ++- 28 files changed, 2334 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/MainActivity.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/api/RadioBrowserApi.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/model/AppSettings.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/model/KaradioModels.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/model/RadioBrowserModels.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/repository/KaradioRepository.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/repository/RadioBrowserRepository.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/data/repository/SettingsRepository.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/navigation/Navigation.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainScreen.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/screens/main/MainViewModel.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/screens/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt create mode 100644 app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt create mode 100644 app/src/main/res/xml/network_security_config.xml 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" }