Implement KaRemote Android app for Karadio32 control
- Add Jetpack Compose UI with Material 3 theming - Implement Karadio32 HTTP API client (play/stop, volume, stations) - Integrate RadioBrowser.info API for station icons and metadata - Add settings screen with server address and theme configuration - Support light/dark/system theme modes and 6 color themes - Configure network security for local HTTP connections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
605726d909
commit
f658c45c23
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
|
||||
|
||||
70
CLAUDE.md
70
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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -2,14 +2,31 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".KaRemoteApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KaRemote" />
|
||||
android:theme="@style/Theme.KaRemote"
|
||||
tools:targetApi="36">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.KaRemote">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
7
app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt
Normal file
7
app/src/main/java/cz/bugsy/karemote/KaRemoteApp.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package cz.bugsy.karemote
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class KaRemoteApp : Application()
|
||||
40
app/src/main/java/cz/bugsy/karemote/MainActivity.kt
Normal file
40
app/src/main/java/cz/bugsy/karemote/MainActivity.kt
Normal file
@ -0,0 +1,40 @@
|
||||
package cz.bugsy.karemote
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cz.bugsy.karemote.ui.navigation.KaRemoteNavHost
|
||||
import cz.bugsy.karemote.ui.theme.KaRemoteTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val settingsViewModel: MainActivityViewModel = hiltViewModel()
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
KaRemoteTheme(
|
||||
themeMode = settings.themeMode,
|
||||
colorTheme = settings.colorTheme
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
KaRemoteNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt
Normal file
24
app/src/main/java/cz/bugsy/karemote/MainActivityViewModel.kt
Normal file
@ -0,0 +1,24 @@
|
||||
package cz.bugsy.karemote
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cz.bugsy.karemote.data.model.AppSettings
|
||||
import cz.bugsy.karemote.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityViewModel @Inject constructor(
|
||||
settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val settings: StateFlow<AppSettings> = settingsRepository.settings
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = AppSettings()
|
||||
)
|
||||
}
|
||||
31
app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt
Normal file
31
app/src/main/java/cz/bugsy/karemote/data/api/KaradioApi.kt
Normal file
@ -0,0 +1,31 @@
|
||||
package cz.bugsy.karemote.data.api
|
||||
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface KaradioApi {
|
||||
|
||||
@GET("/")
|
||||
suspend fun getStatus(@Query("infos") infos: String = ""): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun play(@Query("start") start: String = ""): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun stop(@Query("stop") stop: String = ""): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun setVolume(@Query("volume") volume: Int): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun playStation(@Query("play") stationNumber: Int): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun nextStation(@Query("next") next: String = ""): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun previousStation(@Query("prev") prev: String = ""): String
|
||||
|
||||
@GET("/")
|
||||
suspend fun getStationName(@Query("list") stationNumber: Int): String
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package cz.bugsy.karemote.data.api
|
||||
|
||||
import cz.bugsy.karemote.data.model.RadioBrowserStation
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface RadioBrowserApi {
|
||||
|
||||
@GET("json/stations/search")
|
||||
suspend fun searchStations(
|
||||
@Query("name") name: String,
|
||||
@Query("limit") limit: Int = 5,
|
||||
@Query("hidebroken") hideBroken: Boolean = true
|
||||
): List<RadioBrowserStation>
|
||||
|
||||
@GET("json/stations/byurl")
|
||||
suspend fun searchByUrl(
|
||||
@Query("url") url: String,
|
||||
@Query("limit") limit: Int = 1
|
||||
): List<RadioBrowserStation>
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://de1.api.radio-browser.info/"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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() ?: ""
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package cz.bugsy.karemote.data.repository
|
||||
|
||||
import cz.bugsy.karemote.data.api.KaradioApi
|
||||
import cz.bugsy.karemote.data.model.KaradioStation
|
||||
import cz.bugsy.karemote.data.model.KaradioStatus
|
||||
import cz.bugsy.karemote.data.model.parseKaradioStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.scalars.ScalarsConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class KaradioRepository @Inject constructor() {
|
||||
|
||||
private var karadioApi: KaradioApi? = null
|
||||
private var currentBaseUrl: String? = null
|
||||
|
||||
private fun getApi(serverAddress: String): KaradioApi {
|
||||
val baseUrl = if (serverAddress.startsWith("http")) {
|
||||
serverAddress
|
||||
} else {
|
||||
"http://$serverAddress"
|
||||
}.trimEnd('/') + "/"
|
||||
|
||||
if (karadioApi == null || currentBaseUrl != baseUrl) {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
karadioApi = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(ScalarsConverterFactory.create())
|
||||
.build()
|
||||
.create(KaradioApi::class.java)
|
||||
|
||||
currentBaseUrl = baseUrl
|
||||
}
|
||||
|
||||
return karadioApi!!
|
||||
}
|
||||
|
||||
suspend fun getStatus(serverAddress: String): Result<KaradioStatus> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val response = getApi(serverAddress).getStatus()
|
||||
parseKaradioStatus(response)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun play(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).play()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stop(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).stop()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setVolume(serverAddress: String, volume: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).setVolume(volume.coerceIn(0, 254))
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playStation(serverAddress: String, stationNumber: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).playStation(stationNumber)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nextStation(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).nextStation()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun previousStation(serverAddress: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
getApi(serverAddress).previousStation()
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStationList(serverAddress: String, maxStations: Int = 255): Result<List<KaradioStation>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val stations = mutableListOf<KaradioStation>()
|
||||
for (i in 1 until maxStations) {
|
||||
val response = getApi(serverAddress).getStationName(i)
|
||||
val name = parseStationNameResponse(response)
|
||||
if (name.isNotBlank() && name != "not set") {
|
||||
stations.add(
|
||||
KaradioStation(
|
||||
index = i,
|
||||
name = name,
|
||||
url = "",
|
||||
path = "",
|
||||
port = 80
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
stations
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStationNameResponse(response: String): String {
|
||||
// Response format: "NAME: station name" or similar
|
||||
return response.lines()
|
||||
.firstOrNull { it.contains(":") }
|
||||
?.substringAfter(":")
|
||||
?.trim()
|
||||
?: response.trim()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package cz.bugsy.karemote.data.repository
|
||||
|
||||
import cz.bugsy.karemote.data.api.RadioBrowserApi
|
||||
import cz.bugsy.karemote.data.model.RadioBrowserStation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RadioBrowserRepository @Inject constructor(
|
||||
private val radioBrowserApi: RadioBrowserApi
|
||||
) {
|
||||
|
||||
private val stationCache = mutableMapOf<String, RadioBrowserStation?>()
|
||||
|
||||
suspend fun searchStation(name: String): Result<RadioBrowserStation?> = withContext(Dispatchers.IO) {
|
||||
// Check cache first
|
||||
stationCache[name]?.let { return@withContext Result.success(it) }
|
||||
|
||||
runCatching {
|
||||
val stations = radioBrowserApi.searchStations(name = name, limit = 5)
|
||||
// Find best match - prefer exact name match or highest vote count
|
||||
val station = stations.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
?: stations.maxByOrNull { it.votes }
|
||||
|
||||
// Cache the result
|
||||
stationCache[name] = station
|
||||
station
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStationInfo(stationName: String): RadioBrowserStation? {
|
||||
return searchStation(stationName).getOrNull()
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
stationCache.clear()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package cz.bugsy.karemote.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import cz.bugsy.karemote.data.model.AppSettings
|
||||
import cz.bugsy.karemote.data.model.ColorTheme
|
||||
import cz.bugsy.karemote.data.model.ThemeMode
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private object PreferencesKeys {
|
||||
val SERVER_ADDRESS = stringPreferencesKey("server_address")
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val COLOR_THEME = stringPreferencesKey("color_theme")
|
||||
}
|
||||
|
||||
val settings: Flow<AppSettings> = context.dataStore.data.map { preferences ->
|
||||
AppSettings(
|
||||
serverAddress = preferences[PreferencesKeys.SERVER_ADDRESS] ?: "",
|
||||
themeMode = preferences[PreferencesKeys.THEME_MODE]?.let {
|
||||
ThemeMode.valueOf(it)
|
||||
} ?: ThemeMode.SYSTEM,
|
||||
colorTheme = preferences[PreferencesKeys.COLOR_THEME]?.let {
|
||||
ColorTheme.valueOf(it)
|
||||
} ?: ColorTheme.DEFAULT
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateServerAddress(address: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.SERVER_ADDRESS] = address
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateThemeMode(mode: ThemeMode) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateColorTheme(theme: ColorTheme) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.COLOR_THEME] = theme.name
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt
Normal file
48
app/src/main/java/cz/bugsy/karemote/di/NetworkModule.kt
Normal file
@ -0,0 +1,48 @@
|
||||
package cz.bugsy.karemote.di
|
||||
|
||||
import cz.bugsy.karemote.data.api.RadioBrowserApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
})
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", "KaRemote/1.0")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRadioBrowserApi(okHttpClient: OkHttpClient): RadioBrowserApi {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(RadioBrowserApi.BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(RadioBrowserApi::class.java)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,512 @@
|
||||
package cz.bugsy.karemote.ui.screens.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
viewModel: MainViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("KaRemote") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (uiState.serverAddress.isBlank()) {
|
||||
NoServerConfigured(onNavigateToSettings)
|
||||
} else if (!uiState.isConnected) {
|
||||
ConnectionError(
|
||||
errorMessage = uiState.errorMessage,
|
||||
onRetry = { viewModel.refreshStatus() }
|
||||
)
|
||||
} else {
|
||||
PlayerContent(
|
||||
uiState = uiState,
|
||||
onTogglePlayPause = viewModel::togglePlayPause,
|
||||
onVolumeChange = viewModel::setVolume,
|
||||
onNextStation = viewModel::nextStation,
|
||||
onPreviousStation = viewModel::previousStation,
|
||||
onShowStationPicker = viewModel::showStationPicker
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Station picker bottom sheet
|
||||
if (uiState.showStationPicker) {
|
||||
StationPickerSheet(
|
||||
stations = uiState.stations,
|
||||
isLoading = uiState.isLoadingStations,
|
||||
currentStation = uiState.karadioStatus.stationNumber,
|
||||
onStationSelected = viewModel::playStation,
|
||||
onDismiss = viewModel::hideStationPicker
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoServerConfigured(onNavigateToSettings: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No Server Configured",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Please configure your Karadio32 server address in settings",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
FilledIconButton(
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Go to Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionError(
|
||||
errorMessage: String?,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Connection Error",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
FilledIconButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerContent(
|
||||
uiState: MainUiState,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
onVolumeChange: (Int) -> Unit,
|
||||
onNextStation: () -> Unit,
|
||||
onPreviousStation: () -> Unit,
|
||||
onShowStationPicker: () -> Unit
|
||||
) {
|
||||
val status = uiState.karadioStatus
|
||||
val radioBrowserInfo = uiState.radioBrowserInfo
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Station artwork
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.clickable { onShowStationPicker() },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!radioBrowserInfo?.favicon.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = radioBrowserInfo?.favicon,
|
||||
contentDescription = "Station logo",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Station name
|
||||
Text(
|
||||
text = status.stationName.ifBlank { "No Station" },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onShowStationPicker() }
|
||||
)
|
||||
|
||||
// Genre
|
||||
if (radioBrowserInfo?.genre?.isNotBlank() == true) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = radioBrowserInfo.genre,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Current track/metadata
|
||||
Text(
|
||||
text = status.title.ifBlank { "No metadata available" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Station info
|
||||
if (radioBrowserInfo != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (radioBrowserInfo.bitrate > 0) {
|
||||
Text(
|
||||
text = "${radioBrowserInfo.bitrate} kbps",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
if (radioBrowserInfo.codec.isNotBlank()) {
|
||||
if (radioBrowserInfo.bitrate > 0) {
|
||||
Text(
|
||||
text = " • ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = radioBrowserInfo.codec,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Playback controls
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onPreviousStation,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipPrevious,
|
||||
contentDescription = "Previous station",
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
FilledIconButton(
|
||||
onClick = onTogglePlayPause,
|
||||
modifier = Modifier.size(72.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (status.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (status.isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
FilledIconButton(
|
||||
onClick = onNextStation,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipNext,
|
||||
contentDescription = "Next station",
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Volume control
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Volume",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "${(status.volume * 100 / 254)}%",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Slider(
|
||||
value = status.volume.toFloat(),
|
||||
onValueChange = { onVolumeChange(it.toInt()) },
|
||||
valueRange = 0f..254f,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StationPickerSheet(
|
||||
stations: List<cz.bugsy.karemote.data.model.KaradioStation>,
|
||||
isLoading: Boolean,
|
||||
currentStation: Int,
|
||||
onStationSelected: (Int) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Select Station",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (stations.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No stations found",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(stations) { station ->
|
||||
ListItem(
|
||||
headlineContent = { Text(station.name) },
|
||||
supportingContent = { Text("Station ${station.index}") },
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (station.index == currentStation)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = if (station.index == currentStation)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onStationSelected(station.index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,223 @@
|
||||
package cz.bugsy.karemote.ui.screens.main
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cz.bugsy.karemote.data.model.KaradioStation
|
||||
import cz.bugsy.karemote.data.model.KaradioStatus
|
||||
import cz.bugsy.karemote.data.model.RadioBrowserStation
|
||||
import cz.bugsy.karemote.data.repository.KaradioRepository
|
||||
import cz.bugsy.karemote.data.repository.RadioBrowserRepository
|
||||
import cz.bugsy.karemote.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MainUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isConnected: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val serverAddress: String = "",
|
||||
val karadioStatus: KaradioStatus = KaradioStatus(),
|
||||
val radioBrowserInfo: RadioBrowserStation? = null,
|
||||
val stations: List<KaradioStation> = emptyList(),
|
||||
val isLoadingStations: Boolean = false,
|
||||
val showStationPicker: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
private val karadioRepository: KaradioRepository,
|
||||
private val radioBrowserRepository: RadioBrowserRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private var lastStationName: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.settings.collect { settings ->
|
||||
_uiState.update { it.copy(serverAddress = settings.serverAddress) }
|
||||
if (settings.serverAddress.isNotBlank()) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
_uiState.update { it.copy(isConnected = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
refreshStatus()
|
||||
delay(2000) // Poll every 2 seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
}
|
||||
|
||||
fun refreshStatus() {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
karadioRepository.getStatus(serverAddress)
|
||||
.onSuccess { status ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnected = true,
|
||||
errorMessage = null,
|
||||
karadioStatus = status
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch station info from RadioBrowser if station name changed
|
||||
if (status.stationName != lastStationName && status.stationName.isNotBlank()) {
|
||||
lastStationName = status.stationName
|
||||
fetchRadioBrowserInfo(status.stationName)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
errorMessage = "Connection failed: ${error.localizedMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchRadioBrowserInfo(stationName: String) {
|
||||
viewModelScope.launch {
|
||||
radioBrowserRepository.searchStation(stationName)
|
||||
.onSuccess { station ->
|
||||
_uiState.update { it.copy(radioBrowserInfo = station) }
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.update { it.copy(radioBrowserInfo = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
val isPlaying = _uiState.value.karadioStatus.isPlaying
|
||||
if (isPlaying) {
|
||||
karadioRepository.stop(serverAddress)
|
||||
} else {
|
||||
karadioRepository.play(serverAddress)
|
||||
}
|
||||
delay(300)
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Int) {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
// Optimistically update UI
|
||||
_uiState.update {
|
||||
it.copy(karadioStatus = it.karadioStatus.copy(volume = volume))
|
||||
}
|
||||
|
||||
karadioRepository.setVolume(serverAddress, volume)
|
||||
}
|
||||
}
|
||||
|
||||
fun nextStation() {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
karadioRepository.nextStation(serverAddress)
|
||||
delay(500)
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun previousStation() {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
karadioRepository.previousStation(serverAddress)
|
||||
delay(500)
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun playStation(stationNumber: Int) {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) return@launch
|
||||
|
||||
karadioRepository.playStation(serverAddress, stationNumber)
|
||||
_uiState.update { it.copy(showStationPicker = false) }
|
||||
delay(500)
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun showStationPicker() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showStationPicker = true, isLoadingStations = true) }
|
||||
loadStations()
|
||||
}
|
||||
}
|
||||
|
||||
fun hideStationPicker() {
|
||||
_uiState.update { it.copy(showStationPicker = false) }
|
||||
}
|
||||
|
||||
private fun loadStations() {
|
||||
viewModelScope.launch {
|
||||
val serverAddress = _uiState.value.serverAddress
|
||||
if (serverAddress.isBlank()) {
|
||||
_uiState.update { it.copy(isLoadingStations = false) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
karadioRepository.getStationList(serverAddress, maxStations = 50)
|
||||
.onSuccess { stations ->
|
||||
_uiState.update {
|
||||
it.copy(stations = stations, isLoadingStations = false)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.update { it.copy(isLoadingStations = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package cz.bugsy.karemote.ui.screens.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cz.bugsy.karemote.data.model.AppSettings
|
||||
import cz.bugsy.karemote.data.model.ColorTheme
|
||||
import cz.bugsy.karemote.data.model.ThemeMode
|
||||
import cz.bugsy.karemote.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val serverAddressInput: String = "",
|
||||
val isSaving: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.settings.collect { settings ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
settings = settings,
|
||||
serverAddressInput = settings.serverAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerAddressInput(address: String) {
|
||||
_uiState.update { it.copy(serverAddressInput = address) }
|
||||
}
|
||||
|
||||
fun saveServerAddress() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true) }
|
||||
settingsRepository.updateServerAddress(_uiState.value.serverAddressInput)
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateThemeMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateColorTheme(theme: ColorTheme) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updateColorTheme(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt
Normal file
154
app/src/main/java/cz/bugsy/karemote/ui/theme/Color.kt
Normal file
@ -0,0 +1,154 @@
|
||||
package cz.bugsy.karemote.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Default Theme Colors (Teal)
|
||||
val DefaultPrimaryLight = Color(0xFF006A6A)
|
||||
val DefaultOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val DefaultPrimaryContainerLight = Color(0xFF9CF1F0)
|
||||
val DefaultOnPrimaryContainerLight = Color(0xFF002020)
|
||||
val DefaultSecondaryLight = Color(0xFF4A6363)
|
||||
val DefaultOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val DefaultSecondaryContainerLight = Color(0xFFCCE8E7)
|
||||
val DefaultOnSecondaryContainerLight = Color(0xFF051F1F)
|
||||
val DefaultTertiaryLight = Color(0xFF4B607C)
|
||||
val DefaultOnTertiaryLight = Color(0xFFFFFFFF)
|
||||
val DefaultTertiaryContainerLight = Color(0xFFD3E4FF)
|
||||
val DefaultOnTertiaryContainerLight = Color(0xFF041C35)
|
||||
|
||||
val DefaultPrimaryDark = Color(0xFF80D5D4)
|
||||
val DefaultOnPrimaryDark = Color(0xFF003737)
|
||||
val DefaultPrimaryContainerDark = Color(0xFF004F4F)
|
||||
val DefaultOnPrimaryContainerDark = Color(0xFF9CF1F0)
|
||||
val DefaultSecondaryDark = Color(0xFFB0CCCB)
|
||||
val DefaultOnSecondaryDark = Color(0xFF1B3434)
|
||||
val DefaultSecondaryContainerDark = Color(0xFF324B4B)
|
||||
val DefaultOnSecondaryContainerDark = Color(0xFFCCE8E7)
|
||||
val DefaultTertiaryDark = Color(0xFFB3C8E8)
|
||||
val DefaultOnTertiaryDark = Color(0xFF1C314B)
|
||||
val DefaultTertiaryContainerDark = Color(0xFF334863)
|
||||
val DefaultOnTertiaryContainerDark = Color(0xFFD3E4FF)
|
||||
|
||||
// Blue Theme
|
||||
val BluePrimaryLight = Color(0xFF0061A4)
|
||||
val BlueOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val BluePrimaryContainerLight = Color(0xFFD1E4FF)
|
||||
val BlueOnPrimaryContainerLight = Color(0xFF001D36)
|
||||
val BlueSecondaryLight = Color(0xFF535F70)
|
||||
val BlueOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val BlueSecondaryContainerLight = Color(0xFFD7E3F8)
|
||||
val BlueOnSecondaryContainerLight = Color(0xFF101C2B)
|
||||
|
||||
val BluePrimaryDark = Color(0xFF9FCAFF)
|
||||
val BlueOnPrimaryDark = Color(0xFF003258)
|
||||
val BluePrimaryContainerDark = Color(0xFF00497D)
|
||||
val BlueOnPrimaryContainerDark = Color(0xFFD1E4FF)
|
||||
val BlueSecondaryDark = Color(0xFFBBC7DB)
|
||||
val BlueOnSecondaryDark = Color(0xFF253140)
|
||||
val BlueSecondaryContainerDark = Color(0xFF3B4858)
|
||||
val BlueOnSecondaryContainerDark = Color(0xFFD7E3F8)
|
||||
|
||||
// Green Theme
|
||||
val GreenPrimaryLight = Color(0xFF006E1C)
|
||||
val GreenOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val GreenPrimaryContainerLight = Color(0xFF94F990)
|
||||
val GreenOnPrimaryContainerLight = Color(0xFF002204)
|
||||
val GreenSecondaryLight = Color(0xFF52634F)
|
||||
val GreenOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val GreenSecondaryContainerLight = Color(0xFFD5E8CF)
|
||||
val GreenOnSecondaryContainerLight = Color(0xFF101F10)
|
||||
|
||||
val GreenPrimaryDark = Color(0xFF78DC77)
|
||||
val GreenOnPrimaryDark = Color(0xFF00390A)
|
||||
val GreenPrimaryContainerDark = Color(0xFF005313)
|
||||
val GreenOnPrimaryContainerDark = Color(0xFF94F990)
|
||||
val GreenSecondaryDark = Color(0xFFB9CCB4)
|
||||
val GreenOnSecondaryDark = Color(0xFF253423)
|
||||
val GreenSecondaryContainerDark = Color(0xFF3B4B38)
|
||||
val GreenOnSecondaryContainerDark = Color(0xFFD5E8CF)
|
||||
|
||||
// Orange Theme
|
||||
val OrangePrimaryLight = Color(0xFF8B5000)
|
||||
val OrangeOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val OrangePrimaryContainerLight = Color(0xFFFFDCBE)
|
||||
val OrangeOnPrimaryContainerLight = Color(0xFF2C1600)
|
||||
val OrangeSecondaryLight = Color(0xFF735944)
|
||||
val OrangeOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val OrangeSecondaryContainerLight = Color(0xFFFFDCBE)
|
||||
val OrangeOnSecondaryContainerLight = Color(0xFF291806)
|
||||
|
||||
val OrangePrimaryDark = Color(0xFFFFB871)
|
||||
val OrangeOnPrimaryDark = Color(0xFF4A2800)
|
||||
val OrangePrimaryContainerDark = Color(0xFF693C00)
|
||||
val OrangeOnPrimaryContainerDark = Color(0xFFFFDCBE)
|
||||
val OrangeSecondaryDark = Color(0xFFE1C0A8)
|
||||
val OrangeOnSecondaryDark = Color(0xFF3F2D1A)
|
||||
val OrangeSecondaryContainerDark = Color(0xFF59422E)
|
||||
val OrangeOnSecondaryContainerDark = Color(0xFFFFDCBE)
|
||||
|
||||
// Purple Theme
|
||||
val PurplePrimaryLight = Color(0xFF6750A4)
|
||||
val PurpleOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val PurplePrimaryContainerLight = Color(0xFFEADDFF)
|
||||
val PurpleOnPrimaryContainerLight = Color(0xFF21005E)
|
||||
val PurpleSecondaryLight = Color(0xFF625B71)
|
||||
val PurpleOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val PurpleSecondaryContainerLight = Color(0xFFE8DEF8)
|
||||
val PurpleOnSecondaryContainerLight = Color(0xFF1E192B)
|
||||
|
||||
val PurplePrimaryDark = Color(0xFFD0BCFF)
|
||||
val PurpleOnPrimaryDark = Color(0xFF381E72)
|
||||
val PurplePrimaryContainerDark = Color(0xFF4F378B)
|
||||
val PurpleOnPrimaryContainerDark = Color(0xFFEADDFF)
|
||||
val PurpleSecondaryDark = Color(0xFFCCC2DC)
|
||||
val PurpleOnSecondaryDark = Color(0xFF332D41)
|
||||
val PurpleSecondaryContainerDark = Color(0xFF4A4458)
|
||||
val PurpleOnSecondaryContainerDark = Color(0xFFE8DEF8)
|
||||
|
||||
// Red Theme
|
||||
val RedPrimaryLight = Color(0xFFB3261E)
|
||||
val RedOnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val RedPrimaryContainerLight = Color(0xFFF9DEDC)
|
||||
val RedOnPrimaryContainerLight = Color(0xFF410E0B)
|
||||
val RedSecondaryLight = Color(0xFF77574F)
|
||||
val RedOnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val RedSecondaryContainerLight = Color(0xFFFFDAD4)
|
||||
val RedOnSecondaryContainerLight = Color(0xFF2C1510)
|
||||
|
||||
val RedPrimaryDark = Color(0xFFF2B8B5)
|
||||
val RedOnPrimaryDark = Color(0xFF601410)
|
||||
val RedPrimaryContainerDark = Color(0xFF8C1D18)
|
||||
val RedOnPrimaryContainerDark = Color(0xFFF9DEDC)
|
||||
val RedSecondaryDark = Color(0xFFE7BDB6)
|
||||
val RedOnSecondaryDark = Color(0xFF442925)
|
||||
val RedSecondaryContainerDark = Color(0xFF5D3F3A)
|
||||
val RedOnSecondaryContainerDark = Color(0xFFFFDAD4)
|
||||
|
||||
// Common Colors
|
||||
val BackgroundLight = Color(0xFFFAFDFD)
|
||||
val OnBackgroundLight = Color(0xFF191C1C)
|
||||
val SurfaceLight = Color(0xFFFAFDFD)
|
||||
val OnSurfaceLight = Color(0xFF191C1C)
|
||||
val SurfaceVariantLight = Color(0xFFDAE5E4)
|
||||
val OnSurfaceVariantLight = Color(0xFF3F4948)
|
||||
val OutlineLight = Color(0xFF6F7979)
|
||||
val OutlineVariantLight = Color(0xFFBEC9C8)
|
||||
|
||||
val BackgroundDark = Color(0xFF191C1C)
|
||||
val OnBackgroundDark = Color(0xFFE0E3E3)
|
||||
val SurfaceDark = Color(0xFF191C1C)
|
||||
val OnSurfaceDark = Color(0xFFE0E3E3)
|
||||
val SurfaceVariantDark = Color(0xFF3F4948)
|
||||
val OnSurfaceVariantDark = Color(0xFFBEC9C8)
|
||||
val OutlineDark = Color(0xFF889392)
|
||||
val OutlineVariantDark = Color(0xFF3F4948)
|
||||
|
||||
val ErrorLight = Color(0xFFBA1A1A)
|
||||
val OnErrorLight = Color(0xFFFFFFFF)
|
||||
val ErrorContainerLight = Color(0xFFFFDAD6)
|
||||
val OnErrorContainerLight = Color(0xFF410002)
|
||||
|
||||
val ErrorDark = Color(0xFFFFB4AB)
|
||||
val OnErrorDark = Color(0xFF690005)
|
||||
val ErrorContainerDark = Color(0xFF93000A)
|
||||
val OnErrorContainerDark = Color(0xFFFFDAD6)
|
||||
270
app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt
Normal file
270
app/src/main/java/cz/bugsy/karemote/ui/theme/Theme.kt
Normal file
@ -0,0 +1,270 @@
|
||||
package cz.bugsy.karemote.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import cz.bugsy.karemote.data.model.ColorTheme
|
||||
import cz.bugsy.karemote.data.model.ThemeMode
|
||||
|
||||
// Default Color Schemes
|
||||
private val DefaultLightColorScheme = lightColorScheme(
|
||||
primary = DefaultPrimaryLight,
|
||||
onPrimary = DefaultOnPrimaryLight,
|
||||
primaryContainer = DefaultPrimaryContainerLight,
|
||||
onPrimaryContainer = DefaultOnPrimaryContainerLight,
|
||||
secondary = DefaultSecondaryLight,
|
||||
onSecondary = DefaultOnSecondaryLight,
|
||||
secondaryContainer = DefaultSecondaryContainerLight,
|
||||
onSecondaryContainer = DefaultOnSecondaryContainerLight,
|
||||
tertiary = DefaultTertiaryLight,
|
||||
onTertiary = DefaultOnTertiaryLight,
|
||||
tertiaryContainer = DefaultTertiaryContainerLight,
|
||||
onTertiaryContainer = DefaultOnTertiaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
surfaceVariant = SurfaceVariantLight,
|
||||
onSurfaceVariant = OnSurfaceVariantLight,
|
||||
outline = OutlineLight,
|
||||
outlineVariant = OutlineVariantLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight,
|
||||
errorContainer = ErrorContainerLight,
|
||||
onErrorContainer = OnErrorContainerLight
|
||||
)
|
||||
|
||||
private val DefaultDarkColorScheme = darkColorScheme(
|
||||
primary = DefaultPrimaryDark,
|
||||
onPrimary = DefaultOnPrimaryDark,
|
||||
primaryContainer = DefaultPrimaryContainerDark,
|
||||
onPrimaryContainer = DefaultOnPrimaryContainerDark,
|
||||
secondary = DefaultSecondaryDark,
|
||||
onSecondary = DefaultOnSecondaryDark,
|
||||
secondaryContainer = DefaultSecondaryContainerDark,
|
||||
onSecondaryContainer = DefaultOnSecondaryContainerDark,
|
||||
tertiary = DefaultTertiaryDark,
|
||||
onTertiary = DefaultOnTertiaryDark,
|
||||
tertiaryContainer = DefaultTertiaryContainerDark,
|
||||
onTertiaryContainer = DefaultOnTertiaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
surfaceVariant = SurfaceVariantDark,
|
||||
onSurfaceVariant = OnSurfaceVariantDark,
|
||||
outline = OutlineDark,
|
||||
outlineVariant = OutlineVariantDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark,
|
||||
errorContainer = ErrorContainerDark,
|
||||
onErrorContainer = OnErrorContainerDark
|
||||
)
|
||||
|
||||
// Blue Color Schemes
|
||||
private val BlueLightColorScheme = lightColorScheme(
|
||||
primary = BluePrimaryLight,
|
||||
onPrimary = BlueOnPrimaryLight,
|
||||
primaryContainer = BluePrimaryContainerLight,
|
||||
onPrimaryContainer = BlueOnPrimaryContainerLight,
|
||||
secondary = BlueSecondaryLight,
|
||||
onSecondary = BlueOnSecondaryLight,
|
||||
secondaryContainer = BlueSecondaryContainerLight,
|
||||
onSecondaryContainer = BlueOnSecondaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
private val BlueDarkColorScheme = darkColorScheme(
|
||||
primary = BluePrimaryDark,
|
||||
onPrimary = BlueOnPrimaryDark,
|
||||
primaryContainer = BluePrimaryContainerDark,
|
||||
onPrimaryContainer = BlueOnPrimaryContainerDark,
|
||||
secondary = BlueSecondaryDark,
|
||||
onSecondary = BlueOnSecondaryDark,
|
||||
secondaryContainer = BlueSecondaryContainerDark,
|
||||
onSecondaryContainer = BlueOnSecondaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
// Green Color Schemes
|
||||
private val GreenLightColorScheme = lightColorScheme(
|
||||
primary = GreenPrimaryLight,
|
||||
onPrimary = GreenOnPrimaryLight,
|
||||
primaryContainer = GreenPrimaryContainerLight,
|
||||
onPrimaryContainer = GreenOnPrimaryContainerLight,
|
||||
secondary = GreenSecondaryLight,
|
||||
onSecondary = GreenOnSecondaryLight,
|
||||
secondaryContainer = GreenSecondaryContainerLight,
|
||||
onSecondaryContainer = GreenOnSecondaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
private val GreenDarkColorScheme = darkColorScheme(
|
||||
primary = GreenPrimaryDark,
|
||||
onPrimary = GreenOnPrimaryDark,
|
||||
primaryContainer = GreenPrimaryContainerDark,
|
||||
onPrimaryContainer = GreenOnPrimaryContainerDark,
|
||||
secondary = GreenSecondaryDark,
|
||||
onSecondary = GreenOnSecondaryDark,
|
||||
secondaryContainer = GreenSecondaryContainerDark,
|
||||
onSecondaryContainer = GreenOnSecondaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
// Orange Color Schemes
|
||||
private val OrangeLightColorScheme = lightColorScheme(
|
||||
primary = OrangePrimaryLight,
|
||||
onPrimary = OrangeOnPrimaryLight,
|
||||
primaryContainer = OrangePrimaryContainerLight,
|
||||
onPrimaryContainer = OrangeOnPrimaryContainerLight,
|
||||
secondary = OrangeSecondaryLight,
|
||||
onSecondary = OrangeOnSecondaryLight,
|
||||
secondaryContainer = OrangeSecondaryContainerLight,
|
||||
onSecondaryContainer = OrangeOnSecondaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
private val OrangeDarkColorScheme = darkColorScheme(
|
||||
primary = OrangePrimaryDark,
|
||||
onPrimary = OrangeOnPrimaryDark,
|
||||
primaryContainer = OrangePrimaryContainerDark,
|
||||
onPrimaryContainer = OrangeOnPrimaryContainerDark,
|
||||
secondary = OrangeSecondaryDark,
|
||||
onSecondary = OrangeOnSecondaryDark,
|
||||
secondaryContainer = OrangeSecondaryContainerDark,
|
||||
onSecondaryContainer = OrangeOnSecondaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
// Purple Color Schemes
|
||||
private val PurpleLightColorScheme = lightColorScheme(
|
||||
primary = PurplePrimaryLight,
|
||||
onPrimary = PurpleOnPrimaryLight,
|
||||
primaryContainer = PurplePrimaryContainerLight,
|
||||
onPrimaryContainer = PurpleOnPrimaryContainerLight,
|
||||
secondary = PurpleSecondaryLight,
|
||||
onSecondary = PurpleOnSecondaryLight,
|
||||
secondaryContainer = PurpleSecondaryContainerLight,
|
||||
onSecondaryContainer = PurpleOnSecondaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
private val PurpleDarkColorScheme = darkColorScheme(
|
||||
primary = PurplePrimaryDark,
|
||||
onPrimary = PurpleOnPrimaryDark,
|
||||
primaryContainer = PurplePrimaryContainerDark,
|
||||
onPrimaryContainer = PurpleOnPrimaryContainerDark,
|
||||
secondary = PurpleSecondaryDark,
|
||||
onSecondary = PurpleOnSecondaryDark,
|
||||
secondaryContainer = PurpleSecondaryContainerDark,
|
||||
onSecondaryContainer = PurpleOnSecondaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
// Red Color Schemes
|
||||
private val RedLightColorScheme = lightColorScheme(
|
||||
primary = RedPrimaryLight,
|
||||
onPrimary = RedOnPrimaryLight,
|
||||
primaryContainer = RedPrimaryContainerLight,
|
||||
onPrimaryContainer = RedOnPrimaryContainerLight,
|
||||
secondary = RedSecondaryLight,
|
||||
onSecondary = RedOnSecondaryLight,
|
||||
secondaryContainer = RedSecondaryContainerLight,
|
||||
onSecondaryContainer = RedOnSecondaryContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight
|
||||
)
|
||||
|
||||
private val RedDarkColorScheme = darkColorScheme(
|
||||
primary = RedPrimaryDark,
|
||||
onPrimary = RedOnPrimaryDark,
|
||||
primaryContainer = RedPrimaryContainerDark,
|
||||
onPrimaryContainer = RedOnPrimaryContainerDark,
|
||||
secondary = RedSecondaryDark,
|
||||
onSecondary = RedOnSecondaryDark,
|
||||
secondaryContainer = RedSecondaryContainerDark,
|
||||
onSecondaryContainer = RedOnSecondaryContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KaRemoteTheme(
|
||||
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
colorTheme: ColorTheme = ColorTheme.DEFAULT,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when (colorTheme) {
|
||||
ColorTheme.DEFAULT -> if (darkTheme) DefaultDarkColorScheme else DefaultLightColorScheme
|
||||
ColorTheme.BLUE -> if (darkTheme) BlueDarkColorScheme else BlueLightColorScheme
|
||||
ColorTheme.GREEN -> if (darkTheme) GreenDarkColorScheme else GreenLightColorScheme
|
||||
ColorTheme.ORANGE -> if (darkTheme) OrangeDarkColorScheme else OrangeLightColorScheme
|
||||
ColorTheme.PURPLE -> if (darkTheme) PurpleDarkColorScheme else PurpleLightColorScheme
|
||||
ColorTheme.RED -> if (darkTheme) RedDarkColorScheme else RedLightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@ -1,16 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KaRemote" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KaRemote" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
9
app/src/main/res/xml/network_security_config.xml
Normal file
9
app/src/main/res/xml/network_security_config.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext traffic to local network devices (Karadio32) -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user