docs: complete 100% kdoc coverage and update app branding
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m12s

This commit is contained in:
Krzysztof Cieślik
2026-06-14 07:23:17 +02:00
parent 231ec0cf92
commit c122e0392a
32 changed files with 435 additions and 89 deletions

View File

@@ -1,29 +0,0 @@
package com.example.retroha
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AutomatedClickTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun clickThroughAllTabs() {
Thread.sleep(1000)
val tabResIds = listOf(
R.string.tab_lighting,
R.string.tab_sockets,
R.string.tab_power,
R.string.tab_weather,
R.string.tab_all
)
tabResIds.forEach { resId ->
onView(withText(resId)).perform(click())
Thread.sleep(500)
}
}
}

View File

@@ -1,14 +0,0 @@
package com.example.retroha
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.retroha", appContext.packageName)
}
}

View File

@@ -18,6 +18,7 @@ import android.view.ViewGroup
import android.widget.GridView
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
import com.example.retroha.R
@RunWith(AndroidJUnit4::class)
class MonkeyStressTest {
@get:Rule

View File

@@ -4,6 +4,11 @@ import android.content.Context
import android.os.Bundle
import com.example.retroha.i18n.LocaleHelper
/**
* Base activity class that provides automatic localization support.
* All activities in the application should extend this class to ensure
* dynamic language changes are applied correctly.
*/
abstract class BaseActivity : Activity() {
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(LocaleHelper.setLocale(newBase))

View File

@@ -3,11 +3,18 @@ import android.app.Activity
import android.os.Bundle
import android.widget.*
import com.example.retroha.data.Prefs
import com.example.retroha.i18n.AndroidStrings
import com.example.retroha.i18n.StringKey
import com.example.retroha.network.HaClient
import com.example.retroha.network.HaState
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
/**
* Activity for configuring the connection to the Home Assistant server.
* Handles server URL, authentication token, and refresh interval settings.
* Includes a validation step before saving.
*/
class ConnectionSettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -24,20 +31,24 @@ class ConnectionSettingsActivity : BaseActivity() {
val url = etUrl.text.toString()
val token = etToken.text.toString()
val intervalSec = etRefreshInterval.text.toString().toLongOrNull() ?: 30L
val strings = com.example.retroha.i18n.AndroidStrings(this)
val stringsProvider = AndroidStrings(this)
android.app.AlertDialog.Builder(this)
.setTitle(strings.get(com.example.retroha.i18n.StringKey.DIALOG_WARNING))
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_CHANGE_CONN))
.setPositiveButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_YES_CHANGE)) { _, _ ->
.setTitle(stringsProvider.get(StringKey.DIALOG_WARNING))
.setMessage(stringsProvider.get(StringKey.CONFIRM_CHANGE_CONN))
.setPositiveButton(stringsProvider.get(StringKey.DIALOG_YES_CHANGE)) { _, _ ->
performTestAndSave(url, token, intervalSec, tvStatus)
}
.setNegativeButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_CANCEL), null)
.setNegativeButton(stringsProvider.get(StringKey.DIALOG_CANCEL), null)
.show()
}
}
/**
* Attempts to connect to HA with the provided credentials.
* If successful, saves the configuration and clears the widget cache.
*/
private fun performTestAndSave(url: String, token: String, intervalSec: Long, tvStatus: TextView) {
val strings = com.example.retroha.i18n.AndroidStrings(this)
tvStatus.text = strings.get(com.example.retroha.i18n.StringKey.STATUS_CONNECTING)
val stringsProvider = AndroidStrings(this)
tvStatus.text = stringsProvider.get(StringKey.STATUS_CONNECTING)
tvStatus.setTextColor(0xFF000000.toInt())
val testClient = HaClient.getServiceForTest(url, token)
testClient.getStates().enqueue(object : Callback<List<HaState>> {
@@ -49,21 +60,21 @@ class ConnectionSettingsActivity : BaseActivity() {
Prefs.setSelectedEntities(this@ConnectionSettingsActivity, emptySet())
HaClient.clearCache()
runOnUiThread {
tvStatus.text = strings.get(StringKey.STATUS_SUCCESS)
tvStatus.text = stringsProvider.get(StringKey.STATUS_SUCCESS)
tvStatus.setTextColor(0xFF0056B3.toInt())
Toast.makeText(this@ConnectionSettingsActivity, strings.get(StringKey.TOAST_SAVED_CLEARED), Toast.LENGTH_SHORT).show()
Toast.makeText(this@ConnectionSettingsActivity, stringsProvider.get(StringKey.TOAST_SAVED_CLEARED), Toast.LENGTH_SHORT).show()
finish()
}
} else {
runOnUiThread {
tvStatus.text = "${strings.get(StringKey.STATUS_ERROR)}: ${response.code()}"
tvStatus.text = "${stringsProvider.get(StringKey.STATUS_ERROR)}: ${response.code()}"
tvStatus.setTextColor(0xFFE23A24.toInt())
}
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread {
tvStatus.text = "${strings.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}"
tvStatus.text = "${stringsProvider.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}"
tvStatus.setTextColor(0xFFE23A24.toInt())
}
}

View File

@@ -4,12 +4,18 @@ import android.text.Editable
import android.text.TextWatcher
import android.widget.*
import com.example.retroha.data.Prefs
import com.example.retroha.i18n.AndroidStrings
import com.example.retroha.i18n.StringKey
import com.example.retroha.network.HaClient
import com.example.retroha.network.HaState
import com.example.retroha.ui.EntitySelectionAdapter
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
/**
* Activity for browsing and selecting Home Assistant entities to be displayed as widgets.
* Features real-time filtering and persistent selection storage.
*/
class EntitySelectionActivity : BaseActivity() {
private lateinit var etSearch: EditText
private lateinit var lvEntities: ListView
@@ -20,14 +26,14 @@ class EntitySelectionActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_entity_selection)
val strings = com.example.retroha.i18n.AndroidStrings(this)
val stringsProvider = AndroidStrings(this)
etSearch = findViewById(R.id.etSearch)
lvEntities = findViewById(R.id.lvEntities)
val btnSave = findViewById<Button>(R.id.btnSave)
selectedEntities.addAll(Prefs.getSelectedEntities(this))
btnSave.setOnClickListener {
Prefs.setSelectedEntities(this, selectedEntities)
Toast.makeText(this, strings.get(com.example.retroha.i18n.StringKey.TOAST_ENTITIES_SAVED), Toast.LENGTH_SHORT).show()
Toast.makeText(this, stringsProvider.get(StringKey.TOAST_ENTITIES_SAVED), Toast.LENGTH_SHORT).show()
finish()
}
etSearch.addTextChangedListener(object : TextWatcher {
@@ -48,6 +54,9 @@ class EntitySelectionActivity : BaseActivity() {
}
fetchEntities()
}
/**
* Fetches the complete list of available entities from the HA server.
*/
private fun fetchEntities() {
val token = Prefs.getToken(this)
if (token.isEmpty()) return
@@ -59,18 +68,21 @@ class EntitySelectionActivity : BaseActivity() {
allEntities.sortBy { it.entity_id }
runOnUiThread { filterEntities(etSearch.text.toString()) }
} else {
val strings = com.example.retroha.i18n.AndroidStrings(this@EntitySelectionActivity)
runOnUiThread { Toast.makeText(this@EntitySelectionActivity, "${strings.get(com.example.retroha.i18n.StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
val stringsProvider = AndroidStrings(this@EntitySelectionActivity)
runOnUiThread { Toast.makeText(this@EntitySelectionActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
val strings = com.example.retroha.i18n.AndroidStrings(this@EntitySelectionActivity)
val stringsProvider = AndroidStrings(this@EntitySelectionActivity)
runOnUiThread {
Toast.makeText(this@EntitySelectionActivity, "${strings.get(com.example.retroha.i18n.StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_LONG).show()
Toast.makeText(this@EntitySelectionActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_LONG).show()
}
}
})
}
/**
* Filters the [allEntities] list based on the user's search query.
*/
private fun filterEntities(query: String) {
filteredEntities.clear()
if (query.isEmpty()) {

View File

@@ -1,5 +1,8 @@
package com.example.retroha
import android.os.Bundle
/**
* Activity for displaying the user manual and basic operation instructions.
*/
class InstructionsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@@ -10,8 +10,14 @@ import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.example.retroha.data.Prefs
import com.example.retroha.i18n.AndroidStrings
import com.example.retroha.i18n.StringKey
import com.example.retroha.i18n.LocaleHelper
import com.example.retroha.theme.Colors
/**
* Activity for selecting the application language.
* Serves as the initial entry point on first launch or when triggered from settings.
*/
class LanguageActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -29,9 +35,9 @@ class LanguageActivity : BaseActivity() {
setBackgroundColor(Colors.WHITE)
setPadding(dp(32), dp(32), dp(32), dp(32))
}
val strings = AndroidStrings(this)
val stringsProvider = AndroidStrings(this)
val title = TextView(this).apply {
text = strings.get(StringKey.TITLE_SELECT_LANGUAGE)
text = stringsProvider.get(StringKey.TITLE_SELECT_LANGUAGE)
typeface = android.graphics.Typeface.MONOSPACE
textSize = 18f
setTextColor(Colors.BLACK)

View File

@@ -30,8 +30,13 @@ import android.view.HapticFeedbackConstants
import android.view.WindowManager
import android.graphics.Color
import android.view.Gravity
/**
* The primary dashboard activity.
* Displays a responsive grid of Home Assistant widgets and handles real-time updates.
* Manages domain filtering via tabs and coordinates network requests with [HaClient].
*/
class MainActivity : BaseActivity() {
private lateinit var strings: AndroidStrings
private lateinit var stringsProvider: AndroidStrings
private lateinit var adapter: WidgetAdapter
private val allEntities = mutableListOf<WidgetConfig>()
private val displayedEntities = mutableListOf<WidgetConfig>()
@@ -50,7 +55,7 @@ class MainActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
strings = AndroidStrings(this)
stringsProvider = AndroidStrings(this)
tvStatusIndicator = findViewById(R.id.tvStatusIndicator)
tabContainer = findViewById(R.id.tabContainer)
val gridView = findViewById<GridView>(R.id.gridView)
@@ -78,10 +83,14 @@ class MainActivity : BaseActivity() {
}
findViewById<android.view.View>(R.id.tvTitle).setOnClickListener {
fetchHaStates()
Toast.makeText(this, strings.get(StringKey.STATUS_REFRESHING), Toast.LENGTH_SHORT).show()
Toast.makeText(this, stringsProvider.get(StringKey.STATUS_REFRESHING), Toast.LENGTH_SHORT).show()
}
setupTabs()
}
/**
* Initializes the category filtering tabs.
* Tabs are dynamically generated based on supported domains and current selection.
*/
private fun setupTabs() {
val categories = listOf(
StringKey.TAB_ALL,
@@ -108,7 +117,7 @@ class MainActivity : BaseActivity() {
}
val tabButton = TextView(this).apply {
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
text = strings.get(cat)
text = stringsProvider.get(cat)
typeface = android.graphics.Typeface.MONOSPACE
textSize = 11f
setPadding(dp(12), 0, dp(12), 0)
@@ -135,6 +144,9 @@ class MainActivity : BaseActivity() {
}
}
private fun dp(v: Int) = (v * resources.displayMetrics.density + 0.5f).toInt()
/**
* Filters the list of all entities based on the [currentCategory] and updates the grid.
*/
private fun filterEntities() {
val filtered = when (currentCategory) {
StringKey.TAB_ALL -> allEntities
@@ -160,10 +172,14 @@ class MainActivity : BaseActivity() {
super.onPause()
mainHandler.removeCallbacks(refreshRunnable)
}
/**
* Fetches the latest states from Home Assistant and updates the UI.
* Handles authentication errors and network failures.
*/
private fun fetchHaStates() {
val token = Prefs.getToken(this)
if (token.isEmpty()) {
tvStatusIndicator.text = strings.get(StringKey.STATUS_NO_TOKEN)
tvStatusIndicator.text = stringsProvider.get(StringKey.STATUS_NO_TOKEN)
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
return
}
@@ -173,24 +189,28 @@ class MainActivity : BaseActivity() {
val states = response.body() ?: return
updateEntities(states)
runOnUiThread {
tvStatusIndicator.text = strings.get(StringKey.STATUS_CONNECTED)
tvStatusIndicator.text = stringsProvider.get(StringKey.STATUS_CONNECTED)
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.BLUE)
}
} else {
runOnUiThread {
tvStatusIndicator.text = "${strings.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}"
tvStatusIndicator.text = "${stringsProvider.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}"
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
}
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread {
tvStatusIndicator.text = strings.get(StringKey.STATUS_OFFLINE)
tvStatusIndicator.text = stringsProvider.get(StringKey.STATUS_OFFLINE)
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
}
}
})
}
/**
* Updates the local [allEntities] list with new data from HA.
* Maps raw HA states to [WidgetConfig] objects.
*/
private fun updateEntities(haStates: List<HaState>) {
val selectedIds = Prefs.getSelectedEntities(this)
allEntities.clear()
@@ -253,19 +273,19 @@ class MainActivity : BaseActivity() {
if (idx >= 0 && idx < allEntities.size) {
allEntities[idx] = allEntities[idx].copy(
state = EntityState.TOGGLING,
value = strings[StringKey.STATE_TOGGLING]
value = stringsProvider[StringKey.STATE_TOGGLING]
)
filterEntities()
}
HaClient.getService(this).toggle(domain, ToggleRequest(cfg.entityId)).enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
if (!response.isSuccessful) {
runOnUiThread { Toast.makeText(this@MainActivity, "${strings.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
runOnUiThread { Toast.makeText(this@MainActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
fetchHaStates()
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread { Toast.makeText(this@MainActivity, "${strings.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_SHORT).show() }
runOnUiThread { Toast.makeText(this@MainActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_SHORT).show() }
fetchHaStates()
}
})
@@ -275,19 +295,19 @@ class MainActivity : BaseActivity() {
if (idx >= 0 && idx < allEntities.size) {
allEntities[idx] = allEntities[idx].copy(
state = EntityState.TOGGLING,
value = strings[StringKey.STATE_TOGGLING]
value = stringsProvider[StringKey.STATE_TOGGLING]
)
filterEntities()
}
HaClient.getService(this).toggle(domain, ToggleRequest(cfg.entityId)).enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
if (!response.isSuccessful) {
runOnUiThread { Toast.makeText(this@MainActivity, "${strings.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
runOnUiThread { Toast.makeText(this@MainActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
fetchHaStates()
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread { Toast.makeText(this@MainActivity, "${strings.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_SHORT).show() }
runOnUiThread { Toast.makeText(this@MainActivity, "${stringsProvider.get(StringKey.STATUS_ERROR_NETWORK)}: ${t.message}", Toast.LENGTH_SHORT).show() }
fetchHaStates()
}
})
@@ -306,6 +326,9 @@ class MainActivity : BaseActivity() {
adapter.notifyDataSetChanged()
}
}
/**
* Calculates the optimal number of columns based on screen width and orientation.
*/
private fun resolveColumns(): Int {
val config = resources.configuration
val sw = config.smallestScreenWidthDp

View File

@@ -4,6 +4,13 @@ import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import com.example.retroha.data.Prefs
import com.example.retroha.i18n.AndroidStrings
import com.example.retroha.i18n.StringKey
/**
* Main settings menu activity.
* Provides navigation to connection configuration, entity selection,
* instructions, and language settings.
*/
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -23,15 +30,15 @@ class SettingsActivity : BaseActivity() {
startActivity(intent)
}
findViewById<Button>(R.id.btnDeleteAll).setOnClickListener {
val strings = com.example.retroha.i18n.AndroidStrings(this)
val stringsProvider = AndroidStrings(this)
android.app.AlertDialog.Builder(this)
.setTitle(strings.get(com.example.retroha.i18n.StringKey.DIALOG_WARNING))
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_DELETE_ALL))
.setPositiveButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_YES_DELETE)) { _, _ ->
.setTitle(stringsProvider.get(StringKey.DIALOG_WARNING))
.setMessage(stringsProvider.get(StringKey.CONFIRM_DELETE_ALL))
.setPositiveButton(stringsProvider.get(StringKey.DIALOG_YES_DELETE)) { _, _ ->
Prefs.setSelectedEntities(this, emptySet())
Toast.makeText(this, strings.get(com.example.retroha.i18n.StringKey.TOAST_CLEARED), Toast.LENGTH_SHORT).show()
Toast.makeText(this, stringsProvider.get(StringKey.TOAST_CLEARED), Toast.LENGTH_SHORT).show()
}
.setNegativeButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_CANCEL), null)
.setNegativeButton(stringsProvider.get(StringKey.DIALOG_CANCEL), null)
.show()
}
}

View File

@@ -1,6 +1,11 @@
package com.example.retroha.data
import android.content.Context
import android.content.SharedPreferences
/**
* Singleton object for managing application preferences and persistent state.
* Uses [SharedPreferences] to store connection settings and widget configurations.
*/
object Prefs {
private const val PREFS_NAME = "retroha_prefs"
private const val KEY_URL = "ha_url"
@@ -8,18 +13,34 @@ object Prefs {
private const val KEY_SELECTED_ENTITIES = "selected_entities"
private const val KEY_REFRESH_INTERVAL = "refresh_interval"
private const val KEY_LANGUAGE = "app_language"
private fun getPrefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Gets the currently selected app language code (e.g., "pl", "en"). */
fun getLanguage(context: Context): String? = getPrefs(context).getString(KEY_LANGUAGE, null)
/** Saves the app language preference. */
fun setLanguage(context: Context, lang: String) = getPrefs(context).edit().putString(KEY_LANGUAGE, lang).apply()
/** Gets the Home Assistant server URL. Defaults to "http://". */
fun getUrl(context: Context): String = getPrefs(context).getString(KEY_URL, "http://") ?: "http://"
/** Saves the Home Assistant server URL. */
fun setUrl(context: Context, url: String) = getPrefs(context).edit().putString(KEY_URL, url).apply()
/** Gets the Home Assistant Long-Lived Access Token. */
fun getToken(context: Context): String = getPrefs(context).getString(KEY_TOKEN, "") ?: ""
/** Saves the Home Assistant Access Token. */
fun setToken(context: Context, token: String) = getPrefs(context).edit().putString(KEY_TOKEN, token).apply()
/** Gets the background refresh interval in milliseconds. Defaults to 30s. */
fun getRefreshInterval(context: Context): Long = getPrefs(context).getLong(KEY_REFRESH_INTERVAL, 30000L)
/** Saves the refresh interval in milliseconds. */
fun setRefreshInterval(context: Context, intervalMs: Long) = getPrefs(context).edit().putLong(KEY_REFRESH_INTERVAL, intervalMs).apply()
/** Gets the set of HA entity IDs selected to be displayed as widgets. */
fun getSelectedEntities(context: Context): Set<String> =
getPrefs(context).getStringSet(KEY_SELECTED_ENTITIES, emptySet()) ?: emptySet()
/** Saves the set of selected HA entity IDs. */
fun setSelectedEntities(context: Context, entities: Set<String>) =
getPrefs(context).edit().putStringSet(KEY_SELECTED_ENTITIES, entities).apply()
}

View File

@@ -1,8 +1,16 @@
package com.example.retroha.i18n
import android.content.Context
import com.example.retroha.R
/**
* Android-specific implementation of the [Strings] interface.
* Bridges the universal [StringKey] to Android's `R.string` resource system.
*/
class AndroidStrings(private val context: Context) : Strings {
override fun get(key: StringKey): String = context.getString(
/**
* Resolves the [StringKey] to an actual string from Android resources.
*/
override operator fun get(key: StringKey): String = context.getString(
when (key) {
StringKey.STATE_ON -> R.string.state_on
StringKey.STATE_OFF -> R.string.state_off

View File

@@ -5,13 +5,26 @@ import android.os.Build
import com.example.retroha.data.Prefs
import java.util.Locale
/**
* Utility for handling dynamic runtime locale changes in Android.
* Manages resource configuration updates and context wrapping.
*/
object LocaleHelper {
/**
* Applies the saved language preference to the given context.
*
* @param context The base context to wrap or update.
* @return A new context instance with the updated locale configuration.
*/
fun setLocale(context: Context): Context {
val lang = Prefs.getLanguage(context) ?: "pl"
return updateResources(context, lang)
}
/**
* Internal logic for updating resources based on API level.
*/
private fun updateResources(context: Context, language: String): Context {
val locale = Locale(language)
Locale.setDefault(locale)

View File

@@ -1,27 +1,52 @@
package com.example.retroha.network
import retrofit2.Call
import retrofit2.http.*
/**
* Retrofit interface defining the Home Assistant REST API endpoints.
*/
interface HaApiService {
/** Fetches the current state of all entities. */
@GET("api/states")
fun getStates(): Call<List<HaState>>
/** Toggles the state of an entity in a specific domain. */
@POST("api/services/{domain}/toggle")
fun toggle(@Path("domain") domain: String, @Body body: ToggleRequest): Call<List<HaState>>
/** Sets the specific brightness level for a light entity. */
@POST("api/services/light/turn_on")
fun setBrightness(@Body body: BrightnessRequest): Call<List<HaState>>
}
/**
* Data Transfer Object representing the state of an entity returned by HA.
*/
data class HaState(
val entity_id: String,
val state: String,
val attributes: HaAttributes
)
/**
* Data Transfer Object representing entity attributes (metadata).
*/
data class HaAttributes(
val friendly_name: String?,
val unit_of_measurement: String?,
val brightness: Int? = null
)
/**
* Request body for the toggle service.
*/
data class ToggleRequest(
val entity_id: String
)
/**
* Request body for setting light brightness.
*/
data class BrightnessRequest(
val entity_id: String,
val brightness: Int

View File

@@ -5,22 +5,37 @@ import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* Singleton factory for managing and providing the [HaApiService] instance.
* Handles Retrofit initialization, interceptors for authentication, and caching.
*/
object HaClient {
private var serviceInstance: HaApiService? = null
private val gson = GsonBuilder()
.registerTypeAdapter(HaState::class.java, HaStateAdapter())
.create()
/**
* Provides a cached or newly created [HaApiService] based on saved preferences.
*/
fun getService(context: Context): HaApiService {
val url = Prefs.getUrl(context)
val token = Prefs.getToken(context)
return serviceInstance ?: buildService(url, token).also { serviceInstance = it }
}
/** Clears the current service instance, forcing a rebuild on next request. */
fun clearCache() {
serviceInstance = null
}
/** Creates a temporary [HaApiService] instance with specific credentials for testing. */
fun getServiceForTest(url: String, token: String): HaApiService {
return buildService(url, token)
}
private fun buildService(url: String, token: String): HaApiService {
val baseUrl = if (url.endsWith("/")) url else "$url/"
val okHttpClient = OkHttpClient.Builder()
@@ -32,6 +47,7 @@ object HaClient {
chain.proceed(request)
}
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)

View File

@@ -3,9 +3,18 @@ import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
/**
* Custom GSON TypeAdapter for [HaState].
* Manually parses the Home Assistant state object to handle dynamic attributes
* and deep nesting while maintaining high performance.
*/
class HaStateAdapter : TypeAdapter<HaState>() {
/** Writing is not implemented as this adapter is read-only for current states. */
override fun write(out: JsonWriter, value: HaState?) {
}
/** Parses the JSON response from HA into a [HaState] object. */
override fun read(reader: JsonReader): HaState {
var entityId = ""
var state = ""

View File

@@ -4,18 +4,27 @@ import android.graphics.Canvas
import android.graphics.Paint
import android.view.View
import com.example.retroha.theme.Colors
/**
* A custom checkbox view designed with Bauhaus aesthetics.
* Uses sharp geometric shapes and high-contrast colors.
*/
class BauhausCheckbox(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
/** Current state of the checkbox. Triggers redraw on change. */
var isChecked: Boolean = false
set(value) {
field = value
invalidate()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = (24 * density).toInt()
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
val size = width.toFloat()
val b = 2 * density

View File

@@ -8,6 +8,11 @@ import android.widget.LinearLayout
import android.widget.TextView
import com.example.retroha.network.HaState
import com.example.retroha.theme.Colors
/**
* Adapter for the entity browser list.
* Displays Home Assistant entities with a [BauhausCheckbox] for selection.
*/
class EntitySelectionAdapter(
private val context: Context,
private val items: List<HaState>,
@@ -16,6 +21,8 @@ class EntitySelectionAdapter(
override fun getCount() = items.size
override fun getItem(position: Int) = items[position]
override fun getItemId(position: Int) = position.toLong()
/** Resolves the view for a specific row in the list. */
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val layout = (convertView as? LinearLayout) ?: createLayout()
val checkbox = layout.getChildAt(0) as BauhausCheckbox

View File

@@ -1,6 +1,13 @@
package com.example.retroha.ui
import android.graphics.Typeface
/**
* Central repository for custom Typefaces used in the application.
* Ensures consistent typography across manually drawn UI components.
*/
object Fonts {
/** The standard monospace typeface for regular text. */
val REGULAR: Typeface by lazy { Typeface.create("monospace", Typeface.NORMAL) }
/** The bold monospace typeface for headers and labels. */
val BOLD: Typeface by lazy { Typeface.create("monospace", Typeface.BOLD) }
}

View File

@@ -4,6 +4,11 @@ import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import com.example.retroha.theme.Colors
/**
* Utility for drawing Home Assistant domain icons directly onto a [Canvas].
* Uses purely geometric Bauhaus-style shapes for high performance and stylistic consistency.
*/
object HaIcons {
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
@@ -14,6 +19,17 @@ object HaIcons {
}
private val tmpRect = RectF()
private val tmpPath = Path()
/**
* Renders a domain-specific icon.
*
* @param canvas The canvas to draw on.
* @param domain The HA domain string (e.g., "light", "switch").
* @param x X coordinate for the top-left corner of the icon bounds.
* @param y Y coordinate for the top-left corner of the icon bounds.
* @param size Diameter/Side length of the square icon area.
* @param color The color to use for rendering.
*/
fun draw(canvas: Canvas, domain: String, x: Float, y: Float, size: Float, color: Int) {
fillPaint.color = color
strokePaint.color = color

View File

@@ -5,6 +5,13 @@ import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import com.example.retroha.theme.Colors
/**
* A custom [Drawable] representing a simplified globe icon.
* Used in the UI to indicate language settings.
*
* @param sizePx The intrinsic size of the drawable in pixels.
*/
class LanguageIconDrawable(private val sizePx: Int) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE

View File

@@ -15,6 +15,16 @@ import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
import com.example.retroha.theme.Colors
/**
* A custom dialog for controlling the brightness of a light entity.
* Features a minimalist Bauhaus design and automatic updates on slider release.
*
* @param context The activity context.
* @param entityName Friendly name of the light to display.
* @param initialBrightness The brightness level (0-255) when the dialog is opened.
* @param onBrightnessChanged Callback triggered when the user finishes adjusting the slider.
*/
class LightControlDialog(
context: Context,
private val entityName: String,

View File

@@ -4,22 +4,37 @@ import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import com.example.retroha.model.WidgetConfig
/**
* Adapter for the main dashboard grid.
* Manages the lifecycle and data binding for [WidgetCardView] instances.
*/
class WidgetAdapter(
private val context: Context,
initialItems: List<WidgetConfig>
) : BaseAdapter() {
private val items = mutableListOf<WidgetConfig>().apply { addAll(initialItems) }
/** Callback for short clicks (toggle/execute action). */
var onToggle: ((WidgetConfig) -> Unit)? = null
/** Callback for long clicks (more info / brightness dialog). */
var onLongToggle: ((WidgetConfig) -> Unit)? = null
/**
* Updates the list of items displayed in the grid.
*/
fun updateItems(newItems: List<WidgetConfig>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
override fun getCount() = items.size
override fun getItem(position: Int) = items[position]
override fun getItemId(position: Int) = items[position].id
override fun hasStableIds() = true
/** Provides or recycles a [WidgetCardView] for the grid. */
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val card = convertView as? WidgetCardView ?: WidgetCardView(context).also {
it.onToggle = onToggle

View File

@@ -17,6 +17,11 @@ import com.example.retroha.model.WidgetConfig
import com.example.retroha.model.WidgetInteraction
import com.example.retroha.model.toWidgetInteraction
import com.example.retroha.theme.Colors
/**
* A highly optimized custom [View] that renders a single Home Assistant entity card.
* Part of the "Bauhaus Canvas" engine, it avoids XML overhead by manually drawing everything.
* Supports state-based coloring, pulsing animations for transitions, and haptic feedback.
*/
class WidgetCardView(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val borderPx = dp(2)
@@ -41,7 +46,10 @@ class WidgetCardView(context: Context) : View(context) {
color = Colors.GRAY_MID; textSize = sp(10); typeface = Fonts.REGULAR
}
private val pulseInterpolator = DecelerateInterpolator()
/** Callback for short clicks (toggle/execute action). */
var onToggle: ((WidgetConfig) -> Unit)? = null
/** Callback for long clicks (more info / brightness dialog). */
var onLongToggle: ((WidgetConfig) -> Unit)? = null
private var config: WidgetConfig? = null
private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY
@@ -74,6 +82,12 @@ class WidgetCardView(context: Context) : View(context) {
updateExecIconPath(w - shadowPx)
rebuildLayoutsIfNeeded(w)
}
/**
* Binds new data to the widget and updates the visual state.
* Triggers layout recalculation if text content or view width has changed.
*
* @param cfg The new configuration data for this widget.
*/
fun bind(cfg: WidgetConfig) {
config = cfg
currentInteraction = cfg.domain.toWidgetInteraction()

View File

@@ -1,6 +1,6 @@
<resources>
<string name="app_name">RetroHA</string>
<string name="app_title">HA_PANEL</string>
<string name="app_title">RetroHA</string>
<string name="state_on">ON</string>
<string name="state_off">OFF</string>
<string name="state_unavailable">UNAVAILABLE</string>
@@ -18,7 +18,7 @@
<string name="instruction_1">1. CLICK TILE TO TOGGLE (ON/OFF).</string>
<string name="instruction_2">2. LONG PRESS LIGHT TILE TO OPEN BRIGHTNESS MENU.</string>
<string name="instruction_3">3. USE TABS AT TOP TO FILTER DEVICES.</string>
<string name="instruction_4">4. CLICK \'HA_PANEL\' TITLE TO FORCE REFRESH.</string>
<string name="instruction_4">4. CLICK \'RetroHA\' TITLE TO FORCE REFRESH.</string>
<string name="instruction_5">5. CONNECTION CHANGE CLEARS WIDGET LIST.</string>
<string name="label_url">URL ADDRESS</string>
<string name="label_token">ACCESS TOKEN</string>

View File

@@ -1,6 +1,6 @@
<resources>
<string name="app_name">RetroHA</string>
<string name="app_title">HA_PANEL</string>
<string name="app_title">RetroHA</string>
<string name="state_on">WŁ.</string>
<string name="state_off">WYŁ.</string>
<string name="state_unavailable">NIEOSIĄGALNE</string>
@@ -18,7 +18,7 @@
<string name="instruction_1">1. KLIKNIJ KAFELEK, ABY PRZEŁĄCZYĆ (ON/OFF).</string>
<string name="instruction_2">2. PRZYTRZYMAJ KAFELEK ŚWIATŁA, ABY OTWORZYĆ MENU JASNOŚCI.</string>
<string name="instruction_3">3. UŻYWAJ ZAKŁADEK NA GÓRZE DO FILTROWANIA URZĄDZEŃ.</string>
<string name="instruction_4">4. KLIKNIJ TYTUŁ \'HA_PANEL\', ABY WYMUSIĆ ODŚWIEŻENIE.</string>
<string name="instruction_4">4. KLIKNIJ TYTUŁ \'RetroHA\', ABY WYMUSIĆ ODŚWIEŻENIE.</string>
<string name="instruction_5">5. ZMIANA POŁĄCZENIA CZYŚCI LISTĘ WIDŻETÓW.</string>
<string name="label_url">ADRES URL</string>
<string name="label_token">TOKEN DOSTĘPU</string>

View File

@@ -1,50 +1,102 @@
package com.example.retroha.i18n
/**
* Universal identifiers for translatable strings used across the application.
* Each key maps to a localized string in the platform-specific implementation.
*/
enum class StringKey {
/** Label for 'ON' state. */
STATE_ON,
/** Label for 'OFF' state. */
STATE_OFF,
/** Label for 'UNAVAILABLE' state. */
STATE_UNAVAILABLE,
/** Label for transitioning state. */
STATE_TOGGLING,
/** Button text for Settings. */
BTN_SETTINGS,
/** Placeholder toast for adding widgets. */
TOAST_WIDGET_ADD,
/** Tab label: All. */
TAB_ALL,
/** Tab label: Lighting. */
TAB_LIGHTING,
/** Tab label: Sockets. */
TAB_SOCKETS,
/** Tab label: Power. */
TAB_POWER,
/** Tab label: Weather. */
TAB_WEATHER,
/** Title for entity selection screen. */
TITLE_SETTINGS,
/** Title for connection configuration screen. */
TITLE_CONNECTION,
/** Title for user manual screen. */
TITLE_INSTRUCTIONS,
/** Instruction line 1. */
INSTRUCTION_1,
/** Instruction line 2. */
INSTRUCTION_2,
/** Instruction line 3. */
INSTRUCTION_3,
/** Instruction line 4. */
INSTRUCTION_4,
/** Instruction line 5. */
INSTRUCTION_5,
/** Input label for URL. */
LABEL_URL,
/** Input label for Access Token. */
LABEL_TOKEN,
/** Input label for refresh interval. */
LABEL_REFRESH,
/** Button text for testing and saving connection. */
BTN_TEST_SAVE,
/** Button text for deleting all widgets. */
BTN_DELETE_ALL,
/** Button text for saving selected entities. */
BTN_SAVE_SELECTED,
/** Button text for language change. */
BTN_CHANGE_LANG,
/** Status: Connecting. */
STATUS_CONNECTING,
/** Status: Successfully connected / Online. */
STATUS_CONNECTED,
/** Status: Offline. */
STATUS_OFFLINE,
/** Status error: Home Assistant reported an error. */
STATUS_ERROR_HA,
/** Status error: Authentication token missing. */
STATUS_NO_TOKEN,
/** Title for language selection screen. */
TITLE_SELECT_LANGUAGE,
/** Toast: Refreshing data. */
STATUS_REFRESHING,
/** Status: Success message. */
STATUS_SUCCESS,
/** Toast: Configuration saved and widgets cleared. */
TOAST_SAVED_CLEARED,
/** General error status. */
STATUS_ERROR,
/** Network connection error status. */
STATUS_ERROR_NETWORK,
/** Confirmation message for mass deletion. */
CONFIRM_DELETE_ALL,
/** Warning message for connection change. */
CONFIRM_CHANGE_CONN,
/** Label for brightness level. */
DIALOG_BRIGHTNESS,
/** Dialog button: Set/Apply. */
DIALOG_SET,
/** Dialog button: Cancel. */
DIALOG_CANCEL,
/** Dialog title: Warning. */
DIALOG_WARNING,
/** Dialog button: Confirm connection change. */
DIALOG_YES_CHANGE,
/** Dialog button: Confirm deletion. */
DIALOG_YES_DELETE,
/** Toast: Cache/Dashboard cleared. */
TOAST_CLEARED,
/** Toast: Selected entities saved. */
TOAST_ENTITIES_SAVED
}

View File

@@ -1,4 +1,14 @@
package com.example.retroha.i18n
/**
* Interface defining the contract for retrieving localized strings.
* This allows the business logic to remain platform-independent.
*/
interface Strings {
/**
* Retrieves a localized string for the given key.
* @param key The universal string identifier.
* @return The localized text for the current application language.
*/
operator fun get(key: StringKey): String
}

View File

@@ -1,2 +1,16 @@
package com.example.retroha.model
enum class EntityState { ON, OFF, UNAVAILABLE, TOGGLING }
/**
* Represents the fundamental visual and logical states of a Home Assistant entity.
* Used by the UI layer to determine colors and animations.
*/
enum class EntityState {
/** Entity is active (e.g., light is on, switch is closed). */
ON,
/** Entity is inactive (e.g., light is off, switch is open). */
OFF,
/** Entity is disconnected or its state cannot be determined. */
UNAVAILABLE,
/** Entity is currently transitioning between states (e.g., command sent but not confirmed). */
TOGGLING
}

View File

@@ -1,4 +1,18 @@
package com.example.retroha.model
/**
* Data configuration class for a UI widget.
* Holds all necessary information to render a Home Assistant entity on the dashboard.
*
* @property id Unique local identifier for the widget instance.
* @property entityId The full Home Assistant entity ID (e.g., "light.living_room").
* @property label The display name shown on the card top section.
* @property value The primary state value or measurement (e.g., "ON", "21.5°C").
* @property secondary Subtitle or additional info (e.g., battery level, wattage).
* @property domain The HA domain (light, switch, sensor, etc.) used for icon selection.
* @property state Current logical state for UI coloring and animations.
* @property brightness Optional brightness value (0-255) for light-domain entities.
*/
data class WidgetConfig(
val id: Long,
val entityId: String,

View File

@@ -1,5 +1,22 @@
package com.example.retroha.model
enum class WidgetInteraction { TOGGLE, EXECUTE, READ_ONLY }
/**
* Defines the type of user interaction supported by a widget.
*/
enum class WidgetInteraction {
/** Supports switching between ON and OFF states. */
TOGGLE,
/** Supports one-shot execution (e.g., triggering a script). */
EXECUTE,
/** Information only, no user interaction allowed. */
READ_ONLY
}
/**
* Maps a Home Assistant domain string to its appropriate [WidgetInteraction] type.
*
* @return The interaction type (TOGGLE, EXECUTE, or READ_ONLY) based on the domain logic.
*/
fun String.toWidgetInteraction(): WidgetInteraction = when (this) {
"light",
"switch",

View File

@@ -1,28 +1,65 @@
package com.example.retroha.theme
/**
* Bauhaus-inspired color palette for the application.
* All colors are defined as ARGB integers.
*/
object Colors {
/** Pure black for borders, shadows, and text. */
const val BLACK = 0xFF000000.toInt()
/** Pure white for backgrounds and default states. */
const val WHITE = 0xFFFFFFFF.toInt()
/** Bauhaus red. Used for scripts and high-priority states. */
const val RED = 0xFFE23A24.toInt()
/** Bauhaus yellow. Used for active (ON) highlights. */
const val YELLOW = 0xFFFAD02C.toInt()
/** Bauhaus blue. Used for switches and interactive elements. */
const val BLUE = 0xFF0056B3.toInt()
/** Bauhaus orange. Used for light-domain entities. */
const val ORANGE = 0xFFF4801A.toInt()
/** Bauhaus green. Secondary status color. */
const val GREEN = 0xFF2D7D46.toInt()
/** Bauhaus violet. Used for sensors and measurements. */
const val VIOLET = 0xFF6B3FA0.toInt()
/** Light gray for background elements. */
const val GRAY_LIGHT = 0xFFCCCCCC.toInt()
/** Mid gray for disabled borders. */
const val GRAY_MID = 0xFF888888.toInt()
/** Dark gray for default category stripes. */
const val GRAY_DARK = 0xFF444444.toInt()
// Semantic status colors
/** Card background color when entity is ON. */
const val STATUS_ON = YELLOW
/** Card background color when entity is OFF. */
const val STATUS_OFF = WHITE
/** Card background color when entity is unavailable. */
const val STATUS_UNAVAILABLE = GRAY_LIGHT
/** Card background color during state transition. */
const val STATUS_TOGGLING = WHITE
// Semantic border colors
/** Default card border color. */
const val BORDER_DEFAULT = BLACK
/** Border color during state transition. */
const val BORDER_TOGGLING = BLUE
/** Border color when entity is unavailable. */
const val BORDER_UNAVAILABLE = GRAY_MID
// Domain-specific side stripes
/** Accent stripe for light domain. */
const val STRIPE_LIGHT = ORANGE
/** Accent stripe for switch domain. */
const val STRIPE_SWITCH = BLUE
/** Accent stripe for sensor domain. */
const val STRIPE_SENSOR = VIOLET
/** Accent stripe for binary_sensor domain. */
const val STRIPE_BINARY_SENSOR = VIOLET
/** Accent stripe for script domain. */
const val STRIPE_SCRIPT = RED
/** Accent stripe for automation domain. */
const val STRIPE_AUTOMATION = RED
/** Default accent stripe for unknown domains. */
const val STRIPE_DEFAULT = GRAY_DARK
}