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
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/caches
|
/.idea/
|
||||||
/.idea/libraries
|
.DS_Store
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/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
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build debug APK
|
# Build debug APK (requires Java 17+)
|
||||||
./gradlew assembleDebug
|
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleDebug
|
||||||
|
|
||||||
# Build release APK
|
# Build release APK
|
||||||
./gradlew assembleRelease
|
JAVA_HOME=/path/to/jdk-17 ./gradlew assembleRelease
|
||||||
|
|
||||||
# Run unit tests
|
# Run unit tests
|
||||||
./gradlew test
|
./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)
|
- **Target/Compile SDK**: 36 (Android 15)
|
||||||
- **Kotlin JVM Target**: 11
|
- **Kotlin JVM Target**: 11
|
||||||
- **Build System**: Gradle with Kotlin DSL, version catalog in `gradle/libs.versions.toml`
|
- **Build System**: Gradle with Kotlin DSL, version catalog in `gradle/libs.versions.toml`
|
||||||
|
- **Required JDK**: 17+ (for Hilt plugin)
|
||||||
|
|
||||||
## Architecture
|
## 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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "cz.bugsy.karemote"
|
namespace = "cz.bugsy.karemote"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "cz.bugsy.karemote"
|
applicationId = "cz.bugsy.karemote"
|
||||||
@ -17,6 +19,9 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@ -35,13 +40,66 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Core
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.material)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".KaRemoteApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
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>
|
</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">
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Base application theme. -->
|
<resources>
|
||||||
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.KaRemote" parent="android:Theme.Material.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="android:windowLightStatusBar">false</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. -->
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,16 +1,8 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Base application theme. -->
|
<resources>
|
||||||
<style name="Theme.KaRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.KaRemote" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="android:windowLightStatusBar">true</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. -->
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) 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]
|
[versions]
|
||||||
agp = "8.13.1"
|
agp = "8.13.1"
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.0.21"
|
||||||
coreKtx = "1.10.1"
|
ksp = "2.0.21-1.0.28"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.2.1"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.6.1"
|
||||||
appcompat = "1.6.1"
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
material = "1.10.0"
|
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]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
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-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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
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