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 android.widget.GridView
import org.hamcrest.Description import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher import org.hamcrest.TypeSafeMatcher
import com.example.retroha.R
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MonkeyStressTest { class MonkeyStressTest {
@get:Rule @get:Rule

View File

@@ -4,6 +4,11 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import com.example.retroha.i18n.LocaleHelper 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() { abstract class BaseActivity : Activity() {
override fun attachBaseContext(newBase: Context) { override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(LocaleHelper.setLocale(newBase)) super.attachBaseContext(LocaleHelper.setLocale(newBase))

View File

@@ -3,11 +3,18 @@ import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.widget.* import android.widget.*
import com.example.retroha.data.Prefs 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.HaClient
import com.example.retroha.network.HaState import com.example.retroha.network.HaState
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response 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() { class ConnectionSettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -24,20 +31,24 @@ class ConnectionSettingsActivity : BaseActivity() {
val url = etUrl.text.toString() val url = etUrl.text.toString()
val token = etToken.text.toString() val token = etToken.text.toString()
val intervalSec = etRefreshInterval.text.toString().toLongOrNull() ?: 30L val intervalSec = etRefreshInterval.text.toString().toLongOrNull() ?: 30L
val strings = com.example.retroha.i18n.AndroidStrings(this) val stringsProvider = AndroidStrings(this)
android.app.AlertDialog.Builder(this) android.app.AlertDialog.Builder(this)
.setTitle(strings.get(com.example.retroha.i18n.StringKey.DIALOG_WARNING)) .setTitle(stringsProvider.get(StringKey.DIALOG_WARNING))
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_CHANGE_CONN)) .setMessage(stringsProvider.get(StringKey.CONFIRM_CHANGE_CONN))
.setPositiveButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_YES_CHANGE)) { _, _ -> .setPositiveButton(stringsProvider.get(StringKey.DIALOG_YES_CHANGE)) { _, _ ->
performTestAndSave(url, token, intervalSec, tvStatus) performTestAndSave(url, token, intervalSec, tvStatus)
} }
.setNegativeButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_CANCEL), null) .setNegativeButton(stringsProvider.get(StringKey.DIALOG_CANCEL), null)
.show() .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) { private fun performTestAndSave(url: String, token: String, intervalSec: Long, tvStatus: TextView) {
val strings = com.example.retroha.i18n.AndroidStrings(this) val stringsProvider = AndroidStrings(this)
tvStatus.text = strings.get(com.example.retroha.i18n.StringKey.STATUS_CONNECTING) tvStatus.text = stringsProvider.get(StringKey.STATUS_CONNECTING)
tvStatus.setTextColor(0xFF000000.toInt()) tvStatus.setTextColor(0xFF000000.toInt())
val testClient = HaClient.getServiceForTest(url, token) val testClient = HaClient.getServiceForTest(url, token)
testClient.getStates().enqueue(object : Callback<List<HaState>> { testClient.getStates().enqueue(object : Callback<List<HaState>> {
@@ -49,21 +60,21 @@ class ConnectionSettingsActivity : BaseActivity() {
Prefs.setSelectedEntities(this@ConnectionSettingsActivity, emptySet()) Prefs.setSelectedEntities(this@ConnectionSettingsActivity, emptySet())
HaClient.clearCache() HaClient.clearCache()
runOnUiThread { runOnUiThread {
tvStatus.text = strings.get(StringKey.STATUS_SUCCESS) tvStatus.text = stringsProvider.get(StringKey.STATUS_SUCCESS)
tvStatus.setTextColor(0xFF0056B3.toInt()) 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() finish()
} }
} else { } else {
runOnUiThread { runOnUiThread {
tvStatus.text = "${strings.get(StringKey.STATUS_ERROR)}: ${response.code()}" tvStatus.text = "${stringsProvider.get(StringKey.STATUS_ERROR)}: ${response.code()}"
tvStatus.setTextColor(0xFFE23A24.toInt()) tvStatus.setTextColor(0xFFE23A24.toInt())
} }
} }
} }
override fun onFailure(call: Call<List<HaState>>, t: Throwable) { override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread { 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()) tvStatus.setTextColor(0xFFE23A24.toInt())
} }
} }

View File

@@ -4,12 +4,18 @@ import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.widget.* import android.widget.*
import com.example.retroha.data.Prefs 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.HaClient
import com.example.retroha.network.HaState import com.example.retroha.network.HaState
import com.example.retroha.ui.EntitySelectionAdapter import com.example.retroha.ui.EntitySelectionAdapter
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response 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() { class EntitySelectionActivity : BaseActivity() {
private lateinit var etSearch: EditText private lateinit var etSearch: EditText
private lateinit var lvEntities: ListView private lateinit var lvEntities: ListView
@@ -20,14 +26,14 @@ class EntitySelectionActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_entity_selection) setContentView(R.layout.activity_entity_selection)
val strings = com.example.retroha.i18n.AndroidStrings(this) val stringsProvider = AndroidStrings(this)
etSearch = findViewById(R.id.etSearch) etSearch = findViewById(R.id.etSearch)
lvEntities = findViewById(R.id.lvEntities) lvEntities = findViewById(R.id.lvEntities)
val btnSave = findViewById<Button>(R.id.btnSave) val btnSave = findViewById<Button>(R.id.btnSave)
selectedEntities.addAll(Prefs.getSelectedEntities(this)) selectedEntities.addAll(Prefs.getSelectedEntities(this))
btnSave.setOnClickListener { btnSave.setOnClickListener {
Prefs.setSelectedEntities(this, selectedEntities) 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() finish()
} }
etSearch.addTextChangedListener(object : TextWatcher { etSearch.addTextChangedListener(object : TextWatcher {
@@ -48,6 +54,9 @@ class EntitySelectionActivity : BaseActivity() {
} }
fetchEntities() fetchEntities()
} }
/**
* Fetches the complete list of available entities from the HA server.
*/
private fun fetchEntities() { private fun fetchEntities() {
val token = Prefs.getToken(this) val token = Prefs.getToken(this)
if (token.isEmpty()) return if (token.isEmpty()) return
@@ -59,18 +68,21 @@ class EntitySelectionActivity : BaseActivity() {
allEntities.sortBy { it.entity_id } allEntities.sortBy { it.entity_id }
runOnUiThread { filterEntities(etSearch.text.toString()) } runOnUiThread { filterEntities(etSearch.text.toString()) }
} else { } else {
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_HA)}: ${response.code()}", Toast.LENGTH_SHORT).show() } 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) { override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
val strings = com.example.retroha.i18n.AndroidStrings(this@EntitySelectionActivity) val stringsProvider = AndroidStrings(this@EntitySelectionActivity)
runOnUiThread { 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) { private fun filterEntities(query: String) {
filteredEntities.clear() filteredEntities.clear()
if (query.isEmpty()) { if (query.isEmpty()) {

View File

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

View File

@@ -10,8 +10,14 @@ import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.example.retroha.data.Prefs 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.i18n.LocaleHelper
import com.example.retroha.theme.Colors 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() { class LanguageActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -29,9 +35,9 @@ class LanguageActivity : BaseActivity() {
setBackgroundColor(Colors.WHITE) setBackgroundColor(Colors.WHITE)
setPadding(dp(32), dp(32), dp(32), dp(32)) setPadding(dp(32), dp(32), dp(32), dp(32))
} }
val strings = AndroidStrings(this) val stringsProvider = AndroidStrings(this)
val title = TextView(this).apply { val title = TextView(this).apply {
text = strings.get(StringKey.TITLE_SELECT_LANGUAGE) text = stringsProvider.get(StringKey.TITLE_SELECT_LANGUAGE)
typeface = android.graphics.Typeface.MONOSPACE typeface = android.graphics.Typeface.MONOSPACE
textSize = 18f textSize = 18f
setTextColor(Colors.BLACK) setTextColor(Colors.BLACK)

View File

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

View File

@@ -4,6 +4,13 @@ import android.os.Bundle
import android.widget.Button import android.widget.Button
import android.widget.Toast import android.widget.Toast
import com.example.retroha.data.Prefs 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() { class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -23,15 +30,15 @@ class SettingsActivity : BaseActivity() {
startActivity(intent) startActivity(intent)
} }
findViewById<Button>(R.id.btnDeleteAll).setOnClickListener { findViewById<Button>(R.id.btnDeleteAll).setOnClickListener {
val strings = com.example.retroha.i18n.AndroidStrings(this) val stringsProvider = AndroidStrings(this)
android.app.AlertDialog.Builder(this) android.app.AlertDialog.Builder(this)
.setTitle(strings.get(com.example.retroha.i18n.StringKey.DIALOG_WARNING)) .setTitle(stringsProvider.get(StringKey.DIALOG_WARNING))
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_DELETE_ALL)) .setMessage(stringsProvider.get(StringKey.CONFIRM_DELETE_ALL))
.setPositiveButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_YES_DELETE)) { _, _ -> .setPositiveButton(stringsProvider.get(StringKey.DIALOG_YES_DELETE)) { _, _ ->
Prefs.setSelectedEntities(this, emptySet()) 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() .show()
} }
} }

View File

@@ -1,6 +1,11 @@
package com.example.retroha.data package com.example.retroha.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
/**
* Singleton object for managing application preferences and persistent state.
* Uses [SharedPreferences] to store connection settings and widget configurations.
*/
object Prefs { object Prefs {
private const val PREFS_NAME = "retroha_prefs" private const val PREFS_NAME = "retroha_prefs"
private const val KEY_URL = "ha_url" 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_SELECTED_ENTITIES = "selected_entities"
private const val KEY_REFRESH_INTERVAL = "refresh_interval" private const val KEY_REFRESH_INTERVAL = "refresh_interval"
private const val KEY_LANGUAGE = "app_language" private const val KEY_LANGUAGE = "app_language"
private fun getPrefs(context: Context): SharedPreferences = private fun getPrefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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) 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() 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://" 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() 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, "") ?: "" 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() 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) 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() 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> = fun getSelectedEntities(context: Context): Set<String> =
getPrefs(context).getStringSet(KEY_SELECTED_ENTITIES, emptySet()) ?: emptySet() getPrefs(context).getStringSet(KEY_SELECTED_ENTITIES, emptySet()) ?: emptySet()
/** Saves the set of selected HA entity IDs. */
fun setSelectedEntities(context: Context, entities: Set<String>) = fun setSelectedEntities(context: Context, entities: Set<String>) =
getPrefs(context).edit().putStringSet(KEY_SELECTED_ENTITIES, entities).apply() getPrefs(context).edit().putStringSet(KEY_SELECTED_ENTITIES, entities).apply()
} }

View File

@@ -1,8 +1,16 @@
package com.example.retroha.i18n package com.example.retroha.i18n
import android.content.Context import android.content.Context
import com.example.retroha.R 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 { 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) { when (key) {
StringKey.STATE_ON -> R.string.state_on StringKey.STATE_ON -> R.string.state_on
StringKey.STATE_OFF -> R.string.state_off StringKey.STATE_OFF -> R.string.state_off

View File

@@ -5,13 +5,26 @@ import android.os.Build
import com.example.retroha.data.Prefs import com.example.retroha.data.Prefs
import java.util.Locale import java.util.Locale
/**
* Utility for handling dynamic runtime locale changes in Android.
* Manages resource configuration updates and context wrapping.
*/
object LocaleHelper { 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 { fun setLocale(context: Context): Context {
val lang = Prefs.getLanguage(context) ?: "pl" val lang = Prefs.getLanguage(context) ?: "pl"
return updateResources(context, lang) return updateResources(context, lang)
} }
/**
* Internal logic for updating resources based on API level.
*/
private fun updateResources(context: Context, language: String): Context { private fun updateResources(context: Context, language: String): Context {
val locale = Locale(language) val locale = Locale(language)
Locale.setDefault(locale) Locale.setDefault(locale)

View File

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

View File

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

View File

@@ -3,9 +3,18 @@ import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter 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>() { class HaStateAdapter : TypeAdapter<HaState>() {
/** Writing is not implemented as this adapter is read-only for current states. */
override fun write(out: JsonWriter, value: HaState?) { override fun write(out: JsonWriter, value: HaState?) {
} }
/** Parses the JSON response from HA into a [HaState] object. */
override fun read(reader: JsonReader): HaState { override fun read(reader: JsonReader): HaState {
var entityId = "" var entityId = ""
var state = "" var state = ""

View File

@@ -4,18 +4,27 @@ import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.view.View import android.view.View
import com.example.retroha.theme.Colors 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) { class BauhausCheckbox(context: Context) : View(context) {
private val density = resources.displayMetrics.density private val density = resources.displayMetrics.density
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
/** Current state of the checkbox. Triggers redraw on change. */
var isChecked: Boolean = false var isChecked: Boolean = false
set(value) { set(value) {
field = value field = value
invalidate() invalidate()
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = (24 * density).toInt() val size = (24 * density).toInt()
setMeasuredDimension(size, size) setMeasuredDimension(size, size)
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
val size = width.toFloat() val size = width.toFloat()
val b = 2 * density val b = 2 * density

View File

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

View File

@@ -1,6 +1,13 @@
package com.example.retroha.ui package com.example.retroha.ui
import android.graphics.Typeface import android.graphics.Typeface
/**
* Central repository for custom Typefaces used in the application.
* Ensures consistent typography across manually drawn UI components.
*/
object Fonts { object Fonts {
/** The standard monospace typeface for regular text. */
val REGULAR: Typeface by lazy { Typeface.create("monospace", Typeface.NORMAL) } 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) } 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.Path
import android.graphics.RectF import android.graphics.RectF
import com.example.retroha.theme.Colors 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 { object HaIcons {
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL style = Paint.Style.FILL
@@ -14,6 +19,17 @@ object HaIcons {
} }
private val tmpRect = RectF() private val tmpRect = RectF()
private val tmpPath = Path() 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) { fun draw(canvas: Canvas, domain: String, x: Float, y: Float, size: Float, color: Int) {
fillPaint.color = color fillPaint.color = color
strokePaint.color = color strokePaint.color = color

View File

@@ -5,6 +5,13 @@ import android.graphics.Paint
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import com.example.retroha.theme.Colors 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() { class LanguageIconDrawable(private val sizePx: Int) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE style = Paint.Style.STROKE

View File

@@ -15,6 +15,16 @@ import android.widget.LinearLayout
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import com.example.retroha.theme.Colors 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( class LightControlDialog(
context: Context, context: Context,
private val entityName: String, private val entityName: String,

View File

@@ -4,22 +4,37 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import com.example.retroha.model.WidgetConfig import com.example.retroha.model.WidgetConfig
/**
* Adapter for the main dashboard grid.
* Manages the lifecycle and data binding for [WidgetCardView] instances.
*/
class WidgetAdapter( class WidgetAdapter(
private val context: Context, private val context: Context,
initialItems: List<WidgetConfig> initialItems: List<WidgetConfig>
) : BaseAdapter() { ) : BaseAdapter() {
private val items = mutableListOf<WidgetConfig>().apply { addAll(initialItems) } private val items = mutableListOf<WidgetConfig>().apply { addAll(initialItems) }
/** Callback for short clicks (toggle/execute action). */
var onToggle: ((WidgetConfig) -> Unit)? = null var onToggle: ((WidgetConfig) -> Unit)? = null
/** Callback for long clicks (more info / brightness dialog). */
var onLongToggle: ((WidgetConfig) -> Unit)? = null var onLongToggle: ((WidgetConfig) -> Unit)? = null
/**
* Updates the list of items displayed in the grid.
*/
fun updateItems(newItems: List<WidgetConfig>) { fun updateItems(newItems: List<WidgetConfig>) {
items.clear() items.clear()
items.addAll(newItems) items.addAll(newItems)
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun getCount() = items.size override fun getCount() = items.size
override fun getItem(position: Int) = items[position] override fun getItem(position: Int) = items[position]
override fun getItemId(position: Int) = items[position].id override fun getItemId(position: Int) = items[position].id
override fun hasStableIds() = true override fun hasStableIds() = true
/** Provides or recycles a [WidgetCardView] for the grid. */
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val card = convertView as? WidgetCardView ?: WidgetCardView(context).also { val card = convertView as? WidgetCardView ?: WidgetCardView(context).also {
it.onToggle = onToggle 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.WidgetInteraction
import com.example.retroha.model.toWidgetInteraction import com.example.retroha.model.toWidgetInteraction
import com.example.retroha.theme.Colors 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) { class WidgetCardView(context: Context) : View(context) {
private val density = resources.displayMetrics.density private val density = resources.displayMetrics.density
private val borderPx = dp(2) 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 color = Colors.GRAY_MID; textSize = sp(10); typeface = Fonts.REGULAR
} }
private val pulseInterpolator = DecelerateInterpolator() private val pulseInterpolator = DecelerateInterpolator()
/** Callback for short clicks (toggle/execute action). */
var onToggle: ((WidgetConfig) -> Unit)? = null var onToggle: ((WidgetConfig) -> Unit)? = null
/** Callback for long clicks (more info / brightness dialog). */
var onLongToggle: ((WidgetConfig) -> Unit)? = null var onLongToggle: ((WidgetConfig) -> Unit)? = null
private var config: WidgetConfig? = null private var config: WidgetConfig? = null
private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY
@@ -74,6 +82,12 @@ class WidgetCardView(context: Context) : View(context) {
updateExecIconPath(w - shadowPx) updateExecIconPath(w - shadowPx)
rebuildLayoutsIfNeeded(w) 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) { fun bind(cfg: WidgetConfig) {
config = cfg config = cfg
currentInteraction = cfg.domain.toWidgetInteraction() currentInteraction = cfg.domain.toWidgetInteraction()

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<string name="app_name">RetroHA</string> <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_on">ON</string>
<string name="state_off">OFF</string> <string name="state_off">OFF</string>
<string name="state_unavailable">UNAVAILABLE</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_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_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_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="instruction_5">5. CONNECTION CHANGE CLEARS WIDGET LIST.</string>
<string name="label_url">URL ADDRESS</string> <string name="label_url">URL ADDRESS</string>
<string name="label_token">ACCESS TOKEN</string> <string name="label_token">ACCESS TOKEN</string>

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<string name="app_name">RetroHA</string> <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_on">WŁ.</string>
<string name="state_off">WYŁ.</string> <string name="state_off">WYŁ.</string>
<string name="state_unavailable">NIEOSIĄGALNE</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_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_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_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="instruction_5">5. ZMIANA POŁĄCZENIA CZYŚCI LISTĘ WIDŻETÓW.</string>
<string name="label_url">ADRES URL</string> <string name="label_url">ADRES URL</string>
<string name="label_token">TOKEN DOSTĘPU</string> <string name="label_token">TOKEN DOSTĘPU</string>

View File

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

View File

@@ -1,4 +1,14 @@
package com.example.retroha.i18n package com.example.retroha.i18n
/**
* Interface defining the contract for retrieving localized strings.
* This allows the business logic to remain platform-independent.
*/
interface Strings { 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 operator fun get(key: StringKey): String
} }

View File

@@ -1,2 +1,16 @@
package com.example.retroha.model 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 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( data class WidgetConfig(
val id: Long, val id: Long,
val entityId: String, val entityId: String,

View File

@@ -1,5 +1,22 @@
package com.example.retroha.model 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) { fun String.toWidgetInteraction(): WidgetInteraction = when (this) {
"light", "light",
"switch", "switch",

View File

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