Initial commit

This commit is contained in:
Krzysztof Cieślik
2026-06-13 21:43:53 +02:00
commit 22a3e0fe7e
80 changed files with 4175 additions and 0 deletions

22
.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Handle line endings automatically
* text=auto
# Standard binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.jar binary
*.zip binary
*.aar binary
*.apk binary
# Android specific
*.flat binary
*.webp binary
# Ensure gradlew has LF line endings and is executable
gradlew text eol=lf
gradlew.bat text eol=crlf

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.iml
.gradle
/local.properties
/.idea/
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/deploymentTargetSelector.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store
/build
/app/build
/shared/build
/captures
.externalNativeBuild
.cxx
local.properties
*.bak
*.swp
*.class
.kotlin

833
OPTIMIZATION_PLAN.md Normal file
View File

@@ -0,0 +1,833 @@
# Plan optymalizacji — MyApplication (HA Widget Panel)
Cel: maksymalna wydajność na AllWinner A13 (single-core 1.2GHz, 512MB RAM, Android 4.0, API 14).
---
## Spis treści
1. [Stack technologiczny](#1-stack-technologiczny)
2. [System designu — Bauhaus / Swiss Grid](#2-system-designu--bauhaus--swiss-grid)
3. [Konfiguracja buildu](#3-konfiguracja-buildu)
4. [Rendering — Custom View](#4-rendering--custom-view)
5. [GridView — lista widgetów](#5-gridview--lista-widgetów)
6. [Ikony — Canvas drawing](#6-ikony--canvas-drawing)
7. [StaticLayout — pre-obliczony tekst](#7-staticlayout--pre-obliczony-tekst)
8. [Animacje — GPU only](#8-animacje--gpu-only)
9. [Sieć — OkHttp + Retrofit](#9-sieć--okhttp--retrofit)
10. [JSON — TypeAdaptery zamiast refleksji](#10-json--typeadaptery-zamiast-refleksji)
11. [Wątki — HandlerThread](#11-wątki--handlerthread)
12. [Pamięć — LruCache + SparseArray](#12-pamięć--lrucache--sparsearray)
13. [Inicjalizacja przy starcie](#13-inicjalizacja-przy-starcie)
14. [Języki](#14-języki)
15. [Priorytety implementacji](#15-priorytety-implementacji)
---
## 1. Stack technologiczny
| Element | Wybór | Powód |
|---|---|---|
| Język | **Kotlin** | Identyczny bytecode co Java — zero różnicy runtime |
| UI framework | **Natywne Android API 14** | Zero overhead bibliotek, pełna kontrola |
| Tema | **Theme.Holo** | Natywna, zero zależności, dostępna od API 11 |
| Lista widgetów | **android.widget.GridView** | Natywna, wbudowane recycling widoków, brak Jetpack |
| Sieć | **OkHttp 3.12.x + Retrofit 2.6.4** | Ostatnie wersje wspierające API 14 |
| JSON | **Gson + TypeAdaptery** | Streaming parsing bez refleksji |
| **ZABRONIONE** | AppCompat, Material Design, RecyclerView, Jetpack, Compose, CardView | Zbędny overhead / wymaga API 21+ |
---
## 2. System designu — Bauhaus / Swiss Grid
### Paleta kolorów
```kotlin
object Colors {
const val WHITE = 0xFFFFFFFF.toInt() // tło, tekst na ciemnym
const val BLACK = 0xFF000000.toInt() // tekst, obramowania, cień
const val RED = 0xFFE23A24.toInt() // błędy, alarmy, akcent krytyczny
const val YELLOW = 0xFFFAD02C.toInt() // aktywny stan, akcent główny
const val BLUE = 0xFF0056B3.toInt() // info, przyciski, akcent drugorzędny
const val GRAY = 0xFF888888.toInt() // wyłączone, disabled
}
```
Żadnych innych kolorów. Żadnych gradientów. Żadnego alpha blendingu poza animacjami opacity.
### Typografia — system monospace
```kotlin
object Fonts {
// Wbudowany w każde urządzenie z API 14 — zero overhead, zero KB w APK
val REGULAR: Typeface = Typeface.create("monospace", Typeface.NORMAL)
val BOLD: Typeface = Typeface.create("monospace", Typeface.BOLD)
}
```
Każdy `TextPaint` używa `Fonts.REGULAR` lub `Fonts.BOLD`. Żadnych innych krojów.
### Skala typograficzna
```
Rola Rozmiar Waga Zastosowanie
────────────────────────────────────────────────────
value 20sp BOLD główna wartość encji (21.5°C, ON, 58%)
label 12sp NORMAL nazwa encji
secondary 10sp NORMAL jednostka, czas ostatniej aktualizacji
```
### Siatka spacingu — tylko wielokrotności 4dp
```
Padding wewnętrzny karty: 8dp
Odstęp między kartami: 4dp
Padding ekranu: 8dp
Grubość borderu: 1dp lub 2dp
Szerokość lewego paska: 4dp
```
### Geometria — ZERO zaokrągleń
```kotlin
// ŹLE — zaokrąglone rogi są ZABRONIONE
canvas.drawRoundRect(rect, 8f, 8f, paint)
// DOBRZE — ostre rogi wszędzie
canvas.drawRect(rect, paint)
```
`android:radius` i `corners` w drawable — ZABRONIONE.
### Hard shadow — technika
Zamiast `elevation` — przesunięty czarny `View`:
```xml
<FrameLayout android:padding="4dp">
<!-- Cień: ten sam rozmiar co karta, przesunięty o 4dp -->
<View
android:background="#000000"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp"/>
<!-- Karta na wierzchu -->
<com.example.myapplication.ui.WidgetCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</FrameLayout>
```
---
## 3. Konfiguracja buildu
### `app/build.gradle.kts`
```kotlin
android {
defaultConfig {
minSdk = 14
// Multidex WYŁĄCZONY — bez AppCompat/Material szacowane ~25K metod, poniżej limitu 65K
resourceConfigurations += listOf("pl", "en")
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
// AllWinner A13 = ARM Cortex-A8 = armeabi-v7a
// Wyklucza x86/arm64 → mniejszy APK, szybsza instalacja
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a")
isUniversalApk = false
}
}
}
dependencies {
// Sieć — OK, nie są to UI wrappery
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.gson)
implementation(libs.mpandroidchart)
// USUNIĘTE: AppCompat, Material, core-ktx, multidex, RecyclerView
}
```
### `res/values/themes.xml`
```xml
<style name="AppTheme" parent="@android:style/Theme.Holo">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorBackground">@android:color/black</item>
</style>
```
### Dlaczego odpada multidex
```
AppCompat usunięty: -30 000 metod
Material usunięty: -15 000 metod
core-ktx usunięte: - 5 000 metod
──────────────────────────────────────
Szacowany total: ~25 000 metod ← poniżej limitu 65K
```
---
## 4. Rendering — Custom View
**Zasady:**
- `Paint` / `TextPaint` / `Rect` / `RectF` — pola klasy, nigdy zmienne lokalne w `onDraw`
- `Fonts.REGULAR` / `Fonts.BOLD` przypisane raz przy inicjalizacji obiektu Paint
- `drawRect` zamiast `drawRoundRect` — ostre rogi, szybsze
- Stała wysokość karty w `onMeasure` — eliminuje kosztowne obliczenia `wrap_content`
- Settery z `if (field == value) return` — bez zbędnych `invalidate()`
```kotlin
class WidgetCardView(context: Context) : View(context) {
private val dp = resources.displayMetrics.density
private val paintBg = Paint(Paint.ANTI_ALIAS_FLAG)
private val paintBorder = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f * dp
color = Colors.BLACK
}
private val paintLabel = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = Fonts.REGULAR
textSize = 12f * dp
color = Colors.WHITE
}
private val paintValue = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = Fonts.BOLD
textSize = 20f * dp
color = Colors.WHITE
}
private val paintSecondary = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = Fonts.REGULAR
textSize = 10f * dp
color = Colors.GRAY
}
private val paintStripe = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectBg = Rect()
private val rectStripe = Rect()
private var labelLayout: StaticLayout? = null
private var valueLayout: StaticLayout? = null
private var secondaryLayout: StaticLayout? = null
var label: String = ""
set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() }
var value: String = ""
set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() }
var secondaryText: String = ""
set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() }
var isActive: Boolean = false
set(v) { if (field == v) return; field = v; invalidate() }
var stripeColor: Int = Colors.BLUE
set(v) { if (field == v) return; field = v; invalidate() }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val stripeW = (4f * dp).toInt()
rectBg.set(0, 0, w, h)
rectStripe.set(0, 0, stripeW, h)
rebuildLayouts()
}
private fun rebuildLayouts() {
if (width == 0) return
val contentW = width - rectStripe.width() - (8 * dp).toInt() - (4 * dp).toInt()
labelLayout = buildLayout(label, paintLabel, contentW)
valueLayout = buildLayout(value, paintValue, contentW)
secondaryLayout = buildLayout(secondaryText, paintSecondary, contentW)
}
private fun buildLayout(text: String, paint: TextPaint, w: Int): StaticLayout =
StaticLayout(text, paint, w, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false)
override fun onDraw(canvas: Canvas) {
val dp8 = 8f * dp
val dp4 = 4f * dp
// Tło karty
paintBg.color = if (isActive) Colors.YELLOW else Colors.BLACK
canvas.drawRect(rectBg, paintBg)
// Pionowy pasek koloru (domena encji)
paintStripe.color = stripeColor
canvas.drawRect(rectStripe, paintStripe)
// Obramowanie
canvas.drawRect(rectBg, paintBorder)
// Kolor tekstu zależy od tła
val textColor = if (isActive) Colors.BLACK else Colors.WHITE
paintLabel.color = textColor
paintValue.color = textColor
val textX = rectStripe.width() + dp8
canvas.save()
canvas.translate(textX, dp8)
labelLayout?.draw(canvas)
canvas.restore()
val labelH = labelLayout?.height?.toFloat() ?: 0f
canvas.save()
canvas.translate(textX, dp8 + labelH + dp4)
valueLayout?.draw(canvas)
canvas.restore()
val valueH = valueLayout?.height?.toFloat() ?: 0f
canvas.save()
canvas.translate(textX, dp8 + labelH + dp4 + valueH + dp4)
secondaryLayout?.draw(canvas)
canvas.restore()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = when {
resources.configuration.screenHeightDp < 480 -> (72 * dp).toInt()
else -> (88 * dp).toInt()
}
setMeasuredDimension(w, h)
}
}
```
**Czego unikać:**
- `ConstraintLayout` w itemach gridu — skomplikowany algorytm pomiaru
- `CardView` — wymaga AppCompat, ma elevation
- Zagnieżdżenie ViewGroup > 2 poziomów
- `wrap_content` na wysokości itemów gridu
---
## 5. GridView — lista widgetów
`android.widget.GridView` — natywny od API 1, zero zależności, wbudowane recycling widoków.
Zastępuje `RecyclerView` (Jetpack — zabroniony).
```kotlin
val gridView = GridView(this).apply {
numColumns = when {
resources.configuration.screenWidthDp >= 840 -> 4 // tablet landscape
resources.configuration.screenWidthDp >= 600 -> 3 // tablet portrait
else -> 2 // telefon
}
val dp4 = (4 * resources.displayMetrics.density).toInt()
val dp8 = (8 * resources.displayMetrics.density).toInt()
horizontalSpacing = dp4
verticalSpacing = dp4
setPadding(dp8, dp8, dp8, dp8)
stretchMode = GridView.STRETCH_COLUMN_WIDTH
adapter = widgetAdapter
}
```
### BaseAdapter
```kotlin
class WidgetAdapter(
private val context: Context,
private var items: List<WidgetConfig>
) : BaseAdapter() {
override fun getCount() = items.size
override fun getItem(pos: Int) = items[pos]
override fun getItemId(pos: Int) = items[pos].entityId.hashCode().toLong()
override fun hasStableIds() = true // GridView może reużywać widoki
override fun getView(pos: Int, convertView: View?, parent: ViewGroup): View {
// convertView = recycled view — ZAWSZE reużywaj jeśli istnieje
val card = (convertView as? WidgetCardView) ?: WidgetCardView(context)
val item = items[pos]
card.label = item.friendlyName
card.value = item.state
card.secondaryText = item.lastUpdated
card.isActive = item.isOn
card.stripeColor = domainColor(item.domain)
return card
}
fun update(newItems: List<WidgetConfig>) {
items = newItems
notifyDataSetChanged()
}
// Bezpośrednia aktualizacja stanu encji bez przebudowy adaptera
fun updateState(entityId: String, newState: String, isOn: Boolean) {
val idx = items.indexOfFirst { it.entityId == entityId }
if (idx == -1) return
items = items.toMutableList().also {
it[idx] = it[idx].copy(state = newState, isOn = isOn)
}
// Aktualizuj tylko widoczną kartę jeśli jest na ekranie
val card = gridView?.getChildAt(idx) as? WidgetCardView
if (card != null) {
card.value = newState
card.isActive = isOn
} else {
notifyDataSetChanged()
}
}
private fun domainColor(domain: String) = when (domain) {
"light" -> Colors.YELLOW
"switch" -> Colors.BLUE
"sensor" -> Colors.BLUE
"binary_sensor" -> Colors.BLUE
"script", "scene" -> Colors.RED
else -> Colors.GRAY
}
}
```
**Uwaga:** `GridView` nie ma `DiffUtil` (Jetpack). Dla małej listy widgetów (< 30 pozycji)
`notifyDataSetChanged()` przy zmianie konfiguracji jest akceptowalne. Przy aktualizacji
samego stanu encji — bezpośrednia modyfikacja `WidgetCardView` bez przebudowy adaptera.
---
## 6. Ikony — Canvas drawing
Zakaz bitmap (PNG/JPG). Ikony rysowane bezpośrednio na `Canvas` jako kształty geometryczne.
Styl Bauhaus: proste figury, jeden kolor, zero dekoracji.
```kotlin
object HaIcons {
// Paint jako pole — zero alokacji przy każdym draw()
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f
}
private val tmpRect = RectF()
fun draw(canvas: Canvas, domain: String, x: Float, y: Float, size: Float, color: Int) {
fillPaint.color = color
strokePaint.color = color
when (domain) {
"light" -> drawLight(canvas, x, y, size)
"switch" -> drawSwitch(canvas, x, y, size)
"sensor" -> drawSensor(canvas, x, y, size)
"binary_sensor" -> drawBinarySensor(canvas, x, y, size)
"script", "automation" -> drawScript(canvas, x, y, size)
else -> drawGeneric(canvas, x, y, size)
}
}
private fun drawLight(canvas: Canvas, x: Float, y: Float, s: Float) {
// Kwadrat + promienie — geometryczna "lampa"
tmpRect.set(x + s*0.25f, y + s*0.25f, x + s*0.75f, y + s*0.75f)
canvas.drawRect(tmpRect, fillPaint)
canvas.drawLine(x + s*0.5f, y, x + s*0.5f, y + s*0.2f, strokePaint)
canvas.drawLine(x + s*0.5f, y + s*0.8f, x + s*0.5f, y + s, strokePaint)
canvas.drawLine(x, y + s*0.5f, x + s*0.2f, y + s*0.5f, strokePaint)
canvas.drawLine(x + s*0.8f, y + s*0.5f, x + s, y + s*0.5f, strokePaint)
}
private fun drawSwitch(canvas: Canvas, x: Float, y: Float, s: Float) {
// Prostokąt poziomy + blok po prawej
tmpRect.set(x, y + s*0.35f, x + s, y + s*0.65f)
canvas.drawRect(tmpRect, fillPaint)
tmpRect.set(x + s*0.55f, y + s*0.15f, x + s*0.95f, y + s*0.85f)
canvas.drawRect(tmpRect, fillPaint)
}
private fun drawSensor(canvas: Canvas, x: Float, y: Float, s: Float) {
// Krzyż — pomiar
tmpRect.set(x + s*0.4f, y, x + s*0.6f, y + s)
canvas.drawRect(tmpRect, fillPaint)
tmpRect.set(x, y + s*0.4f, x + s, y + s*0.6f)
canvas.drawRect(tmpRect, fillPaint)
}
private fun drawBinarySensor(canvas: Canvas, x: Float, y: Float, s: Float) {
// Kwadrat w kwadracie
tmpRect.set(x + s*0.1f, y + s*0.1f, x + s*0.9f, y + s*0.9f)
canvas.drawRect(tmpRect, strokePaint)
tmpRect.set(x + s*0.3f, y + s*0.3f, x + s*0.7f, y + s*0.7f)
canvas.drawRect(tmpRect, fillPaint)
}
private fun drawScript(canvas: Canvas, x: Float, y: Float, s: Float) {
// Trzy poziome paski — lista komend
val h = s * 0.12f
val gap = s * 0.25f
for (i in 0..2) {
tmpRect.set(x, y + gap * i + gap * 0.5f, x + s, y + gap * i + gap * 0.5f + h)
canvas.drawRect(tmpRect, fillPaint)
}
}
private fun drawGeneric(canvas: Canvas, x: Float, y: Float, s: Float) {
tmpRect.set(x + s*0.15f, y + s*0.15f, x + s*0.85f, y + s*0.85f)
canvas.drawRect(tmpRect, fillPaint)
}
}
```
Ponieważ ikony są prostymi operacjami Canvas (kilka `drawRect` / `drawLine`), rasteryzacja
jest natychmiastowa — nie potrzeba cache'owania bitmap.
---
## 7. StaticLayout — pre-obliczony tekst
`canvas.drawText()` mierzy tekst przy każdym `onDraw`. `StaticLayout` robi to raz.
```kotlin
private fun buildLayout(text: String, paint: TextPaint, widthPx: Int): StaticLayout =
StaticLayout(text, paint, widthPx, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false)
// W onDraw — zero mierzenia:
canvas.save()
canvas.translate(x, y)
layout.draw(canvas)
canvas.restore()
```
**Reguły:**
- Przebuduj tylko gdy zmienia się tekst **lub** `onSizeChanged`
- `if (field == value) return` w każdym setterze przed `invalidate()`
- Guard `if (width == 0) return` w `rebuildLayouts()`
---
## 8. Animacje — GPU only
Tylko właściwości animowane przez GPU via `ViewPropertyAnimator`.
Zero animacji zmieniających layout — `width`/`height`/`margin`/`padding` wywołują
`requestLayout()` co zabija wydajność.
```kotlin
// Wysunięcie panelu konfiguracji z dołu
fun revealPanel(panel: View) {
panel.visibility = View.VISIBLE
panel.translationY = panel.height.toFloat()
panel.animate()
.translationY(0f)
.alpha(1f)
.setDuration(200)
.setInterpolator(DecelerateInterpolator())
.start()
}
// Chowanie panelu
fun hidePanel(panel: View) {
panel.animate()
.translationY(panel.height.toFloat())
.alpha(0f)
.setDuration(150)
.setInterpolator(LinearInterpolator())
.withEndAction { panel.visibility = View.GONE }
.start()
}
// Feedback dotknięcia karty
fun tapFeedback(card: View) {
card.animate()
.alpha(0.4f)
.setDuration(80)
.setInterpolator(LinearInterpolator())
.withEndAction {
card.animate().alpha(1f).setDuration(120).start()
}
.start()
}
```
### Tabela dozwolonych właściwości
```
Właściwość GPU Zastosowanie
──────────────────────────────────────────────────
translationX/Y ✓ przesuwanie paneli, drawer
scaleX/Y ✓ powiększenie aktywnego widgetu
alpha ✓ fade, feedback dotknięcia
rotation ✓ obrót ikony ładowania
width/height ✗ requestLayout() — ZABRONIONE
margin/padding ✗ requestLayout() — ZABRONIONE
layout_weight ✗ requestLayout() — ZABRONIONE
backgroundColor ✗ software render — zmień kolor przez Paint w onDraw
```
**Interpolatory:** tylko `LinearInterpolator` lub `DecelerateInterpolator`.
**Czas trwania:** 80ms250ms. Żadnych sprężyn, odbić, elastyczności.
---
## 9. Sieć — OkHttp + Retrofit
Jeden singleton na całą aplikację — `ConnectionPool` jest drogi w tworzeniu.
```kotlin
object NetworkModule {
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectionPool(ConnectionPool(3, 30, TimeUnit.SECONDS))
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.cache(Cache(File(context.cacheDir, "ha_http"), 2L * 1024 * 1024))
.addInterceptor(AuthInterceptor(token))
.build()
}
val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(buildGson()))
.build()
}
}
```
---
## 10. JSON — TypeAdaptery zamiast refleksji
Gson domyślnie używa refleksji — na A13 odczuwalne przy każdym parsowaniu.
TypeAdapter = ręczne parsowanie strumieniowe, 3-5x szybsze.
```kotlin
class HaEntityTypeAdapter : TypeAdapter<HaEntity>() {
override fun read(reader: JsonReader): HaEntity {
var entityId = ""; var state = ""; var friendlyName = ""
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"entity_id" -> entityId = reader.nextString()
"state" -> state = reader.nextString()
"attributes" -> {
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"friendly_name" -> friendlyName = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
}
else -> reader.skipValue() // nie parsuj niepotrzebnych pól
}
}
reader.endObject()
return HaEntity(entityId, state, friendlyName)
}
override fun write(out: JsonWriter, value: HaEntity?) {}
}
```
---
## 11. Wątki — HandlerThread
Na single-core nie twórz wielu wątków — context switching jest kosztowny.
Jeden `HandlerThread` na całą komunikację z HA.
```kotlin
object HaWorker {
private val thread = HandlerThread("ha-worker").also { it.start() }
val handler = Handler(thread.looper)
val mainThread = Handler(Looper.getMainLooper())
fun post(block: () -> Unit) = handler.post(block)
fun postDelayed(ms: Long, block: () -> Unit) = handler.postDelayed(block, ms)
fun cancelAll() = handler.removeCallbacksAndMessages(null)
}
```
**Dlaczego `HandlerThread` zamiast `Executors.newSingleThreadExecutor()`:**
- `postDelayed` dla retry z exponential backoff
- `removeCallbacks` do anulowania zakolejkowanych zadań bez dodatkowej synchronizacji
- Wbudowana kolejka z priorytetem
---
## 12. Pamięć — LruCache + SparseArray
### Cache stanów encji
```kotlin
object EntityStateCache {
private val cache = LruCache<String, EntityState>(50)
fun get(id: String) = cache.get(id)
fun put(id: String, s: EntityState) = cache.put(id, s)
fun evictAll() = cache.evictAll()
}
```
### SparseArray zamiast HashMap dla kluczy Int
```kotlin
// ŹLE — autoboxing Int→Integer przy każdym get/put
val viewTypes = HashMap<Int, Int>()
// DOBRZE — zero alokacji, zoptymalizowane dla kluczy int
val viewTypes = SparseArray<Int>()
```
### onTrimMemory — oddaj pamięć gdy system prosi
```kotlin
class MainActivity : Activity() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
EntityStateCache.evictAll()
}
}
}
```
### WeakReference w callbackach sieciowych
```kotlin
// ŹLE — anonimowa klasa trzyma referencję do Activity
client.newCall(req).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
textView.text = "..." // wyciek Activity po rotacji!
}
})
// DOBRZE
class StateCallback(activity: MainActivity) : Callback {
private val ref = WeakReference(activity)
override fun onResponse(call: Call, response: Response) {
ref.get()?.runOnUiThread { /* aktualizuj UI */ }
}
override fun onFailure(call: Call, e: IOException) {}
}
```
---
## 13. Inicjalizacja przy starcie
```kotlin
class App : Application() {
override fun onCreate() {
super.onCreate()
// Pierwsze połączenie w tle — tworzy connection pool przed pierwszym UI request
HaWorker.post { warmupNetwork() }
}
private fun warmupNetwork() {
try {
NetworkModule.okHttpClient
.newCall(pingRequest())
.execute()
.close()
} catch (_: IOException) {}
}
}
```
### Co kiedy jest przeliczane
```
Zdarzenie Co się dzieje
──────────────────────────────────────────────────────────────────
Start aplikacji warmup connection pool w tle
onSizeChanged karty rebuild StaticLayout (1x na widget)
onDraw drawRect + HaIcons.draw + layout.draw
→ ZERO alokacji, ZERO parsowania
Zmiana stanu encji bezpośredni setter na WidgetCardView
→ tylko ta karta wywołuje invalidate()
onTrimMemory (MODERATE+) EntityStateCache.evictAll() → RAM zwolniony
Obrócenie ekranu stany encji z EntityStateCache (bez sieci)
```
---
## 14. Języki
```
res/values/strings.xml angielski (default)
res/values-pl/strings.xml polski
```
`resourceConfigurations += listOf("pl", "en")` wycina pozostałe tłumaczenia z bibliotek
narzędziowych (~200KB oszczędności w APK).
---
## 15. Priorytety implementacji
```
Priorytet Optymalizacja Wpływ
──────────────────────────────────────────────────────────────
KRYTYCZNY Brak AppCompat/Material → Theme.Holo RAM / metody
Custom View — drawRect, brak zaokrągleń rendering
Ikony jako Canvas draw (brak bitmap) rendering / RAM
Jeden OkHttpClient singleton RAM
DUŻY StaticLayout (pre-obliczony tekst) rendering
TypeAdaptery Gson CPU / parsing
HandlerThread (1 wątek tła) CPU
GridView z BaseAdapter + hasStableIds rendering
ŚREDNI EntityStateCache (LruCache) sieć / RAM
SparseArray zamiast HashMap RAM
onTrimMemory → czyszczenie cache RAM
Animacje tylko GPU (ViewPropertyAnimator) jank
Bezpośredni setter na kartę (bez adapter) rendering
MAŁY ABI splits (armeabi-v7a only) APK size
isShrinkResources = true APK size
resourceConfigurations (pl + en) APK size
WeakReference w callbackach memory leaks
```
---
## Zależności po zmianach
```toml
# gradle/libs.versions.toml
# USUNIĘTO: AppCompat, Material, core-ktx, multidex
[versions]
okhttp = "3.12.13"
retrofit = "2.6.4"
gson = "2.10.1"
mpandroidchart = "v3.1.0"
[libraries]
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version.ref = "mpandroidchart" }
```

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# RetroHA
**RetroHA** is a lightweight and efficient dashboard for Home Assistant, specifically designed for legacy Android tablets intended to serve as wall-mounted home control panels.
## Key Features
* **Bauhaus Canvas Engine**: A custom graphics engine. The interface does not use standard Android XML views but is manually rendered on a `Canvas`. This ensures extreme fluidity even on decade-old devices.
* **Low-End Hardware Optimization**: Minimal RAM usage, no unnecessary libraries, and aggressive caching of graphical layouts.
* **Bauhaus Design**: A minimalist aesthetic inspired by the Bauhaus school—high contrast, geometric shapes, and maximum legibility.
* **Kiosk Mode**: Built-in "Wake Lock" support to prevent the screen from turning off while the panel is active.
* **Rapid Control**:
* Tile Click: Toggle or Execute.
* Light Long Press: Brightness menu with "slide and release" gesture (auto-save).
## Libraries & Acknowledgments
This project utilizes the following Open Source libraries (compliant with F-Droid requirements):
* **Retrofit** (Square Inc.) — Apache License 2.0: Type-safe HTTP client for Android and Java.
* **OkHttp** (Square Inc.) — Apache License 2.0: An efficient HTTP client for Android.
* **Gson** (Google) — Apache License 2.0: A Java library that can be used to convert Java Objects into their JSON representation.
* **MPAndroidChart** (PhilJay) — Apache License 2.0: A powerful Android chart view / graph view library.
* **AndroidX Libraries** (Google) — Apache License 2.0: Component libraries for Android.
## Technical Requirements
* **Android**: minSdk 14 (Support back to Android 4.0 ICS), optimized for Android 4.4+.
* **Home Assistant**: Requires Server URL and a Long-Lived Access Token.
## Roadmap
The current project status and planned features are detailed in [TASKS.md](./TASKS.md).
## License
This project is licensed under the **GNU GPL v3**. See the repository for details.

64
ROADMAP.md Normal file
View File

@@ -0,0 +1,64 @@
# RetroHA — Roadmap
## Sieć i integracja z HA
- [x] Ekran konfiguracji: adres serwera HA + Long-Lived Access Token
- [x] Klient REST (Retrofit) — pobieranie stanu encji (`/api/states`)
- [x] Wywoływanie serwisów HA (`/api/services/<domain>/<service>`) — toggle, execute
- [ ] WebSocket (`/api/websocket`) — subskrypcja zdarzeń `state_changed` dla aktualizacji w czasie rzeczywistym
- [ ] Logika ponownego łączenia po utracie połączenia (exponential backoff)
- [x] Wskaźnik stanu połączenia w górnym pasku (połączono / rozłączono / łączenie)
- [x] Obsługa błędów: timeout, 401 Unauthorized, 404 Not Found
## Architektura — warstwa sieciowa (moduł `shared`)
- [x] `HaApiService` — interfejs Retrofit z endpointami REST
- [x] `HaEntity` — DTO mapujący JSON z `/api/states`
- [ ] `HaWebSocketClient` — klient WebSocket z obsługą `auth`, `subscribe_events`
- [x] `EntityStateCache` — mapujący `entity_id → EntityState` (w `Prefs` i `MainActivity`)
- [ ] `HaWorker``HandlerThread` jako jeden wątek sieciowy (single-core device)
- [x] `WeakReference` / `runOnUiThread` w callbackach sieciowych — unikanie wycieków `Activity`
## Konfiguracja widgetów
- [x] Ekran dodawania widgetu — przeglądarka encji HA (lista z wyszukiwarką)
- [x] Trwałe zapisywanie listy widgetów (JSON w `SharedPreferences`)
- [x] Usuwanie widgetu (przez ekran wyboru encji)
- [ ] Zmiana kolejności widgetów (drag & drop lub przyciski ↑↓)
- [x] Filtrowanie widgetów przez zakładki (Oświetlenie, Gniazdka, Moc, itp.)
## Obsługa typów encji
- [x] `light` — sterowanie jasnością (slider, auto-zapis po puszczeniu)
- [ ] `light` — sterowanie kolorem (jeśli obsługuje)
- [ ] `climate` — wyświetlanie temperatury docelowej, trybu HVAC; widok szczegółów
- [x] `sensor` / `binary_sensor` — wyświetlanie wartości i stanów
- [x] `switch` — przełączanie ON/OFF
- [x] `automation` / `script` — wywoływanie akcji (EXECUTE)
## Wydajność i stabilność
- [x] **Bauhaus Canvas** — autorski, lekki silnik graficzny (ręczne rysowanie na `Canvas`)
- [x] Optymalizacja `onDraw` — cachowanie `StaticLayout`, unikanie alokacji w pętli rysowania
- [x] Obsługa bardzo starych urządzeń (optymalizacja pod słabe procesory)
- [ ] ProGuard / R8 dla release buildu
## UI
- [x] Zakładki (Tabs) do szybkiego filtrowania domen
- [x] Obsługa języków (PL/EN)
- [x] Tryb Kiosk (Wake Lock) — ekran nie gaśnie przy widocznej aplikacji
- [x] Responsywność — automatyczna liczba kolumn (Grid)
## i18n
- [x] Pełne wsparcie dla języka polskiego (PL)
- [x] Pełne wsparcie dla języka angielskiego (EN)
- [ ] Tłumaczenia na języki: DE, FR, UK
## Testy
- [x] Testy instrumentalne — `AppNavigationTest`, `AutomatedClickTest`
- [x] Monkey Stress Test — testowanie stabilności pod dużym obciążeniem
- [x] Unit testy — `ExampleUnitTest`

47
TASKS.md Normal file
View File

@@ -0,0 +1,47 @@
# RetroHA — Task Tracker
Methodology: Incremental Kanban (Simple .md file)
## [PHASE 1] Foundations (Proof of Concept)
- [x] Project configuration (minSdk 19, Kotlin, Retrofit)
- [x] Login screen (URL + Token)
- [x] Configuration persistence in SharedPreferences
- [x] Basic REST client (fetch `/api/states`)
- [x] Connection verification (Online/Offline status)
## [PHASE 2] Custom Graphics Engine (Bauhaus Canvas)
- [x] `WidgetCardView` implementation (Custom View)
- [x] Manual tile rendering (background, border, shadow)
- [x] Manual text rendering with `StaticLayout` optimization
- [x] Icon system (`HaIcons.kt`) rendered on Canvas
- [x] Widget grid (GridView) with responsive column count
## [PHASE 3] Data Integration & Entity Selection
- [x] Mapping Home Assistant states to visual styles
- [x] Entity browser and selection screen (Searchable)
- [x] Persistent storage of selected widget list
- [x] Domain filtering via Tabs (Lighting, Sockets, Power, etc.)
- [x] Automatic background data refresh
## [PHASE 4] Interactions & Control
- [x] Short click handling (Toggle / Execute)
- [x] Pulsing animation during state transitions (Toggling)
- [x] Brightness control menu (LightControlDialog)
- [x] Button removal from brightness menu (Auto-save on release)
- [x] Dismiss menu on outside touch
## [PHASE 5] UX Polish & Stability
- [x] Internationalization (PL/EN support)
- [x] Kiosk Mode (Wake Lock - prevent screen sleep)
- [x] `onDraw` performance optimization (zero allocation in draw loop)
- [x] Stability testing (Monkey Stress Test)
## Future Development
- [ ] WebSocket implementation for real-time updates
- [ ] Support for `climate` and `media_player` domains
- [ ] RGB color support for lighting
---
*Legend:*
- [x] - Done
- [ ] - To Do

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

72
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,72 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.dokka)
}
android {
namespace = "com.example.retroha"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.example.retroha"
minSdk = 14
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = false
vectorDrawables.useSupportLibrary = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "x86", "x86_64")
isUniversalApk = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
implementation(project(":shared"))
// OkHttp 3.12.x — ostatnia wersja wspierająca API 19
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Retrofit 2 + Gson converter
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.gson)
// MPAndroidChart
implementation(libs.mpandroidchart)
testImplementation(libs.junit)
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test:rules:1.5.0")
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,50 @@
package com.example.retroha
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
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 AppNavigationTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun testSettingsNavigationAndForms() {
Thread.sleep(1000)
onView(withId(R.id.btnSettings)).perform(click())
Thread.sleep(500)
onView(withId(R.id.btnEntitySelection)).check(matches(isDisplayed()))
onView(withId(R.id.btnInstructions)).check(matches(isDisplayed()))
onView(withId(R.id.btnGoToConnection)).check(matches(isDisplayed()))
onView(withId(R.id.btnEntitySelection)).perform(click())
Thread.sleep(500)
onView(withId(R.id.etSearch)).check(matches(isDisplayed()))
onView(withId(R.id.etSearch)).perform(replaceText("light"), closeSoftKeyboard())
Thread.sleep(500)
pressBack()
Thread.sleep(500)
onView(withId(R.id.btnInstructions)).perform(click())
Thread.sleep(500)
pressBack()
Thread.sleep(500)
onView(withId(R.id.btnGoToConnection)).perform(click())
Thread.sleep(500)
onView(withId(R.id.etUrl)).check(matches(isDisplayed()))
onView(withId(R.id.etToken)).check(matches(isDisplayed()))
onView(withId(R.id.etRefreshInterval)).perform(replaceText("60"), closeSoftKeyboard())
Thread.sleep(500)
pressBack()
Thread.sleep(500)
pressBack()
Thread.sleep(500)
onView(withId(R.id.gridView)).check(matches(isDisplayed()))
}
}

View File

@@ -0,0 +1,23 @@
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 tabs = listOf("OŚWIETLENIE", "GNIAZDKA", "MOC", "POGODA", "WSZYSTKO")
tabs.forEach { tabName ->
onView(withText(tabName)).perform(click())
Thread.sleep(500)
}
}
}

View File

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,65 @@
package com.example.retroha
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withId
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
import java.util.Random
import androidx.test.espresso.ViewInteraction
import android.view.View
import org.hamcrest.Matcher
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import android.view.ViewGroup
import android.widget.GridView
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
@RunWith(AndroidJUnit4::class)
class MonkeyStressTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
private val random = Random()
@Test
fun runMonkeyTest() {
val iterations = 50
val tabs = listOf("WSZYSTKO", "OŚWIETLENIE", "GNIAZDKA", "MOC", "POGODA")
for (i in 1..iterations) {
val actionType = random.nextInt(3)
try {
when (actionType) {
0 -> {
val tab = tabs[random.nextInt(tabs.size)]
onView(withText(tab)).perform(click())
}
1 -> {
onView(withId(R.id.gridView)).perform(clickRandomItem())
}
2 -> {
onView(withId(R.id.btnSettings)).perform(click())
Thread.sleep(500)
androidx.test.espresso.Espresso.pressBack()
}
}
} catch (e: Exception) {
}
Thread.sleep(200)
}
}
private fun clickRandomItem(): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = withId(R.id.gridView)
override fun getDescription(): String = "Kliknięcie losowego elementu w GridView"
override fun perform(uiController: UiController, view: View) {
val gridView = view as GridView
if (gridView.childCount > 0) {
val randomIndex = random.nextInt(gridView.childCount)
gridView.getChildAt(randomIndex).performClick()
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.RetroHA">
<activity
android:name=".LanguageActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".MainActivity" />
<activity android:name=".SettingsActivity" />
<activity android:name=".ConnectionSettingsActivity" />
<activity android:name=".InstructionsActivity" />
<activity android:name=".EntitySelectionActivity" />
</application>
</manifest>

View File

@@ -0,0 +1,10 @@
package com.example.retroha
import android.app.Activity
import android.os.Bundle
import com.example.retroha.i18n.LocaleHelper
abstract class BaseActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
LocaleHelper.setLocale(this)
super.onCreate(savedInstanceState)
}
}

View File

@@ -0,0 +1,72 @@
package com.example.retroha
import android.app.Activity
import android.os.Bundle
import android.widget.*
import com.example.retroha.data.Prefs
import com.example.retroha.network.HaClient
import com.example.retroha.network.HaState
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class ConnectionSettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_connection_settings)
val etUrl = findViewById<EditText>(R.id.etUrl)
val etToken = findViewById<EditText>(R.id.etToken)
val etRefreshInterval = findViewById<EditText>(R.id.etRefreshInterval)
val btnSave = findViewById<Button>(R.id.btnTestAndSave)
val tvStatus = findViewById<TextView>(R.id.tvStatus)
etUrl.setText(Prefs.getUrl(this))
etToken.setText(Prefs.getToken(this))
etRefreshInterval.setText((Prefs.getRefreshInterval(this) / 1000).toString())
btnSave.setOnClickListener {
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)
android.app.AlertDialog.Builder(this)
.setTitle("UWAGA")
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_CHANGE_CONN))
.setPositiveButton("TAK, ZMIEŃ") { _, _ ->
performTestAndSave(url, token, intervalSec, tvStatus)
}
.setNegativeButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_ANULUJ), null)
.show()
}
}
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)
tvStatus.setTextColor(0xFF000000.toInt())
val testClient = HaClient.getServiceForTest(url, token)
testClient.getStates().enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
if (response.isSuccessful) {
Prefs.setUrl(this@ConnectionSettingsActivity, url)
Prefs.setToken(this@ConnectionSettingsActivity, token)
Prefs.setRefreshInterval(this@ConnectionSettingsActivity, intervalSec * 1000L)
Prefs.setSelectedEntities(this@ConnectionSettingsActivity, emptySet())
HaClient.clearCache()
runOnUiThread {
tvStatus.text = "POŁĄCZONO POMYŚLNIE"
tvStatus.setTextColor(0xFF0056B3.toInt())
Toast.makeText(this@ConnectionSettingsActivity, "Zapisano i wyczyszczono widżety", Toast.LENGTH_SHORT).show()
finish()
}
} else {
runOnUiThread {
tvStatus.text = "BŁĄD: ${response.code()}"
tvStatus.setTextColor(0xFFE23A24.toInt())
}
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread {
tvStatus.text = "BŁĄD SIECI: ${t.message}"
tvStatus.setTextColor(0xFFE23A24.toInt())
}
}
})
}
}

View File

@@ -0,0 +1,88 @@
package com.example.retroha
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.widget.*
import com.example.retroha.data.Prefs
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
class EntitySelectionActivity : BaseActivity() {
private lateinit var etSearch: EditText
private lateinit var lvEntities: ListView
private var allEntities = mutableListOf<HaState>()
private var filteredEntities = mutableListOf<HaState>()
private var selectedEntities = mutableSetOf<String>()
private var adapter: EntitySelectionAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_entity_selection)
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, "Wybrane encje zapisane", Toast.LENGTH_SHORT).show()
finish()
}
etSearch.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { filterEntities(s.toString()) }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
lvEntities.onItemClickListener = AdapterView.OnItemClickListener { _, view, position, _ ->
val entityId = filteredEntities[position].entity_id
if (selectedEntities.contains(entityId)) {
selectedEntities.remove(entityId)
} else {
selectedEntities.add(entityId)
}
val layout = view as? LinearLayout
val checkbox = layout?.getChildAt(0) as? com.example.retroha.ui.BauhausCheckbox
checkbox?.isChecked = selectedEntities.contains(entityId)
}
fetchEntities()
}
private fun fetchEntities() {
val token = Prefs.getToken(this)
if (token.isEmpty()) return
HaClient.getService(this).getStates().enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
if (response.isSuccessful) {
allEntities.clear()
allEntities.addAll(response.body() ?: emptyList())
allEntities.sortBy { it.entity_id }
runOnUiThread { filterEntities(etSearch.text.toString()) }
} else {
runOnUiThread { Toast.makeText(this@EntitySelectionActivity, "Błąd HA: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread {
Toast.makeText(this@EntitySelectionActivity, "Błąd: ${t.message}", Toast.LENGTH_LONG).show()
}
}
})
}
private fun filterEntities(query: String) {
filteredEntities.clear()
if (query.isEmpty()) {
filteredEntities.addAll(allEntities)
} else {
val q = query.lowercase()
allEntities.filterTo(filteredEntities) {
it.entity_id.lowercase().contains(q) ||
(it.attributes.friendly_name?.lowercase()?.contains(q) == true)
}
}
updateList()
}
private fun updateList() {
adapter = EntitySelectionAdapter(this, filteredEntities, selectedEntities)
lvEntities.adapter = adapter
}
}

View File

@@ -0,0 +1,8 @@
package com.example.retroha
import android.os.Bundle
class InstructionsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_instructions)
}
}

View File

@@ -0,0 +1,91 @@
package com.example.retroha
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.example.retroha.data.Prefs
import com.example.retroha.i18n.LocaleHelper
import com.example.retroha.theme.Colors
class LanguageActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isFirstLaunch = Prefs.getLanguage(this) == null
val fromSettings = intent.getBooleanExtra("from_settings", false)
if (!isFirstLaunch && !fromSettings) {
LocaleHelper.setLocale(this)
startMainActivity()
return
}
val density = resources.displayMetrics.density
fun dp(v: Int) = (v * density + 0.5f).toInt()
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(Colors.WHITE)
setPadding(dp(32), dp(32), dp(32), dp(32))
}
val title = TextView(this).apply {
text = "WYBIERZ JĘZYK\nSELECT LANGUAGE"
typeface = android.graphics.Typeface.MONOSPACE
textSize = 18f
setTextColor(Colors.BLACK)
gravity = Gravity.CENTER
setPadding(0, 0, 0, dp(64))
}
root.addView(title)
fun createBauhausButton(label: String, color: Int, onClick: () -> Unit): View {
val container = FrameLayout(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(60)).apply {
setMargins(0, 0, 0, dp(24))
}
}
val shadow = View(this).apply {
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56)).apply {
gravity = Gravity.BOTTOM or Gravity.RIGHT
}
setBackgroundColor(Colors.BLACK)
}
val btn = Button(this).apply {
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56)).apply {
gravity = Gravity.TOP or Gravity.LEFT
}
text = label
typeface = android.graphics.Typeface.MONOSPACE
setTextColor(Colors.WHITE)
val bgDrawable = android.graphics.drawable.GradientDrawable().apply {
shape = android.graphics.drawable.GradientDrawable.RECTANGLE
setColor(color)
setStroke(dp(2), Colors.BLACK)
}
background = bgDrawable
setOnClickListener { onClick() }
}
container.addView(shadow)
container.addView(btn)
return container
}
root.addView(createBauhausButton("POLSKI", Colors.BLUE) {
Prefs.setLanguage(this, "pl")
LocaleHelper.setLocale(this)
startMainActivity()
})
root.addView(createBauhausButton("ENGLISH", Colors.YELLOW) {
Prefs.setLanguage(this, "en")
LocaleHelper.setLocale(this)
startMainActivity()
})
setContentView(root)
}
private fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
}
}

View File

@@ -0,0 +1,313 @@
package com.example.retroha
import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.content.Intent
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.SeekBar
import android.widget.Toast
import android.widget.GridView
import com.example.retroha.i18n.AndroidStrings
import com.example.retroha.i18n.StringKey
import com.example.retroha.model.EntityState
import com.example.retroha.model.WidgetConfig
import com.example.retroha.model.WidgetInteraction
import com.example.retroha.model.toWidgetInteraction
import com.example.retroha.data.Prefs
import com.example.retroha.ui.WidgetAdapter
import com.example.retroha.network.HaClient
import com.example.retroha.network.HaState
import com.example.retroha.network.ToggleRequest
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.view.HapticFeedbackConstants
import android.view.WindowManager
import android.graphics.Color
import android.view.Gravity
class MainActivity : BaseActivity() {
private lateinit var strings: AndroidStrings
private lateinit var adapter: WidgetAdapter
private val allEntities = mutableListOf<WidgetConfig>()
private val displayedEntities = mutableListOf<WidgetConfig>()
private val mainHandler = Handler(Looper.getMainLooper())
private var currentCategory = "WSZYSTKO"
private lateinit var tvStatusIndicator: TextView
private lateinit var tabContainer: LinearLayout
private val refreshRunnable = object : Runnable {
override fun run() {
fetchHaStates()
val interval = Prefs.getRefreshInterval(this@MainActivity)
mainHandler.postDelayed(this, interval)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
strings = AndroidStrings(this)
tvStatusIndicator = findViewById(R.id.tvStatusIndicator)
tabContainer = findViewById(R.id.tabContainer)
val gridView = findViewById<GridView>(R.id.gridView)
gridView.numColumns = resolveColumns()
adapter = WidgetAdapter(this, displayedEntities)
adapter.onToggle = { cfg ->
findViewById<View>(android.R.id.content)?.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
handleToggle(cfg)
}
adapter.onLongToggle = { cfg ->
findViewById<View>(android.R.id.content)?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
handleLongToggle(cfg)
}
gridView.adapter = adapter
findViewById<android.view.View>(R.id.btnSettings).setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
findViewById<TextView>(R.id.btnSettings).apply {
val bg = android.graphics.drawable.GradientDrawable().apply {
shape = android.graphics.drawable.GradientDrawable.RECTANGLE
setColor(com.example.retroha.theme.Colors.BLUE)
setStroke(dp(2), com.example.retroha.theme.Colors.BLACK)
}
background = bg
}
findViewById<android.view.View>(R.id.tvTitle).setOnClickListener {
fetchHaStates()
Toast.makeText(this, "Odświeżanie...", Toast.LENGTH_SHORT).show()
}
setupTabs()
}
private fun setupTabs() {
val categories = listOf("WSZYSTKO", "OŚWIETLENIE", "GNIAZDKA", "MOC", "POGODA")
val mainView = findViewById<View>(android.R.id.content)
tabContainer.post {
tabContainer.removeAllViews()
categories.forEach { cat ->
val isSelected = cat == currentCategory
val tabFrame = FrameLayout(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(34)).apply {
setMargins(dp(4), dp(4), dp(4), dp(4))
}
}
val shadow = View(this).apply {
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
setBackgroundColor(com.example.retroha.theme.Colors.BLACK)
translationX = dp(2).toFloat()
translationY = dp(2).toFloat()
}
val tabButton = TextView(this).apply {
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
text = cat
typeface = android.graphics.Typeface.MONOSPACE
textSize = 11f
setPadding(dp(12), 0, dp(12), 0)
gravity = Gravity.CENTER
val bgDrawable = android.graphics.drawable.GradientDrawable().apply {
shape = android.graphics.drawable.GradientDrawable.RECTANGLE
setColor(if (isSelected) com.example.retroha.theme.Colors.YELLOW else com.example.retroha.theme.Colors.WHITE)
setStroke(dp(2), com.example.retroha.theme.Colors.BLACK)
}
background = bgDrawable
setTextColor(com.example.retroha.theme.Colors.BLACK)
setOnClickListener {
if (currentCategory == cat) return@setOnClickListener
currentCategory = cat
setupTabs()
filterEntities()
mainView?.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
}
}
tabFrame.addView(shadow)
tabFrame.addView(tabButton)
tabContainer.addView(tabFrame)
}
}
}
private fun dp(v: Int) = (v * resources.displayMetrics.density + 0.5f).toInt()
private fun filterEntities() {
val filtered = when (currentCategory) {
"WSZYSTKO" -> allEntities
"OŚWIETLENIE" -> allEntities.filter { it.domain == "light" }
"GNIAZDKA" -> allEntities.filter {
it.domain == "switch" || it.domain == "outlet" ||
it.entityId.contains("socket") || it.entityId.contains("plug")
}
"MOC" -> allEntities.filter { it.secondary.contains("W") || it.entityId.contains("power") || it.entityId.contains("energy") || it.entityId.contains("current_consumption") }
"POGODA" -> allEntities.filter { it.domain == "weather" || it.domain == "sensor" && (it.entityId.contains("temp") || it.entityId.contains("hum")) }
else -> allEntities
}
runOnUiThread {
adapter.updateItems(filtered)
}
}
override fun onResume() {
super.onResume()
mainHandler.removeCallbacks(refreshRunnable)
mainHandler.post(refreshRunnable)
}
override fun onPause() {
super.onPause()
mainHandler.removeCallbacks(refreshRunnable)
}
private fun fetchHaStates() {
val token = Prefs.getToken(this)
if (token.isEmpty()) {
tvStatusIndicator.text = "BRAK TOKENU"
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
return
}
HaClient.getService(this).getStates().enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
if (response.isSuccessful) {
val states = response.body() ?: return
updateEntities(states)
runOnUiThread {
tvStatusIndicator.text = "ONLINE"
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.BLUE)
}
} else {
runOnUiThread {
tvStatusIndicator.text = "BŁĄD HA: ${response.code()}"
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
}
}
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread {
tvStatusIndicator.text = "OFFLINE"
tvStatusIndicator.setTextColor(com.example.retroha.theme.Colors.RED)
}
}
})
}
private fun updateEntities(haStates: List<HaState>) {
val selectedIds = Prefs.getSelectedEntities(this)
allEntities.clear()
haStates.filter { ha ->
selectedIds.contains(ha.entity_id)
}.forEachIndexed { index, ha ->
val domain = ha.entity_id.split(".")[0]
val state = when (ha.state) {
"on" -> EntityState.ON
"off" -> EntityState.OFF
"unavailable" -> EntityState.UNAVAILABLE
else -> EntityState.OFF
}
allEntities.add(WidgetConfig(
id = index.toLong(),
entityId = ha.entity_id,
label = ha.attributes.friendly_name ?: ha.entity_id,
value = ha.state.uppercase(),
secondary = ha.attributes.unit_of_measurement ?: "",
domain = domain,
state = state,
brightness = ha.attributes.brightness
))
}
runOnUiThread {
filterEntities()
}
}
private fun handleLongToggle(cfg: WidgetConfig) {
if (cfg.domain != "light") return
com.example.retroha.ui.LightControlDialog(
this,
cfg.label,
cfg.brightness ?: 0,
onBrightnessChanged = { brightness ->
setLightBrightness(cfg.entityId, brightness)
}
).show()
}
private fun setLightBrightness(entityId: String, brightness: Int) {
HaClient.getService(this).setBrightness(com.example.retroha.network.BrightnessRequest(entityId, brightness))
.enqueue(object : Callback<List<HaState>> {
override fun onResponse(call: Call<List<HaState>>, response: Response<List<HaState>>) {
fetchHaStates()
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {}
})
}
private fun handleToggle(cfg: WidgetConfig) {
val idx = allEntities.indexOfFirst { it.entityId == cfg.entityId }
if (idx < 0) return
when (cfg.domain.toWidgetInteraction()) {
WidgetInteraction.TOGGLE -> doToggle(idx, cfg)
WidgetInteraction.EXECUTE -> doExecute(idx, cfg)
WidgetInteraction.READ_ONLY -> Unit
}
}
private fun doToggle(idx: Int, cfg: WidgetConfig) {
val domain = cfg.entityId.split(".")[0]
if (idx >= 0 && idx < allEntities.size) {
allEntities[idx] = allEntities[idx].copy(
state = EntityState.TOGGLING,
value = strings[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, "BŁĄD HA: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
fetchHaStates()
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread { Toast.makeText(this@MainActivity, "BŁĄD SIECI: ${t.message}", Toast.LENGTH_SHORT).show() }
fetchHaStates()
}
})
}
private fun doExecute(idx: Int, cfg: WidgetConfig) {
val domain = cfg.entityId.split(".")[0]
if (idx >= 0 && idx < allEntities.size) {
allEntities[idx] = allEntities[idx].copy(
state = EntityState.TOGGLING,
value = strings[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, "BŁĄD HA: ${response.code()}", Toast.LENGTH_SHORT).show() }
}
fetchHaStates()
}
override fun onFailure(call: Call<List<HaState>>, t: Throwable) {
runOnUiThread { Toast.makeText(this@MainActivity, "BŁĄD SIECI: ${t.message}", Toast.LENGTH_SHORT).show() }
fetchHaStates()
}
})
}
override fun onDestroy() {
super.onDestroy()
mainHandler.removeCallbacks(refreshRunnable)
mainHandler.removeCallbacksAndMessages(null)
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
mainHandler.removeCallbacks(refreshRunnable)
allEntities.clear()
displayedEntities.clear()
adapter.notifyDataSetChanged()
}
}
private fun resolveColumns(): Int {
val config = resources.configuration
val sw = config.smallestScreenWidthDp
val isLandscape = config.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
return when {
sw >= 720 -> if (isLandscape) 5 else 4
sw >= 600 -> if (isLandscape) 4 else 3
else -> if (isLandscape) 3 else 2
}
}
}

View File

@@ -0,0 +1,38 @@
package com.example.retroha
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import com.example.retroha.data.Prefs
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
findViewById<Button>(R.id.btnEntitySelection).setOnClickListener {
startActivity(Intent(this, EntitySelectionActivity::class.java))
}
findViewById<Button>(R.id.btnGoToConnection).setOnClickListener {
startActivity(Intent(this, ConnectionSettingsActivity::class.java))
}
findViewById<Button>(R.id.btnInstructions).setOnClickListener {
startActivity(Intent(this, InstructionsActivity::class.java))
}
findViewById<Button>(R.id.btnChangeLang).setOnClickListener {
val intent = Intent(this, LanguageActivity::class.java)
intent.putExtra("from_settings", true)
startActivity(intent)
}
findViewById<Button>(R.id.btnDeleteAll).setOnClickListener {
val strings = com.example.retroha.i18n.AndroidStrings(this)
android.app.AlertDialog.Builder(this)
.setTitle("UWAGA")
.setMessage(strings.get(com.example.retroha.i18n.StringKey.CONFIRM_DELETE_ALL))
.setPositiveButton("TAK, USUŃ") { _, _ ->
Prefs.setSelectedEntities(this, emptySet())
Toast.makeText(this, "Wyczyszczono pulpit", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(strings.get(com.example.retroha.i18n.StringKey.DIALOG_ANULUJ), null)
.show()
}
}
}

View File

@@ -0,0 +1,25 @@
package com.example.retroha.data
import android.content.Context
import android.content.SharedPreferences
object Prefs {
private const val PREFS_NAME = "retroha_prefs"
private const val KEY_URL = "ha_url"
private const val KEY_TOKEN = "ha_token"
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)
fun getLanguage(context: Context): String? = getPrefs(context).getString(KEY_LANGUAGE, null)
fun setLanguage(context: Context, lang: String) = getPrefs(context).edit().putString(KEY_LANGUAGE, lang).apply()
fun getUrl(context: Context): String = getPrefs(context).getString(KEY_URL, "http:
fun setUrl(context: Context, url: String) = getPrefs(context).edit().putString(KEY_URL, url).apply()
fun getToken(context: Context): String = getPrefs(context).getString(KEY_TOKEN, "") ?: ""
fun setToken(context: Context, token: String) = getPrefs(context).edit().putString(KEY_TOKEN, token).apply()
fun getRefreshInterval(context: Context): Long = getPrefs(context).getLong(KEY_REFRESH_INTERVAL, 30000L)
fun setRefreshInterval(context: Context, intervalMs: Long) = getPrefs(context).edit().putLong(KEY_REFRESH_INTERVAL, intervalMs).apply()
fun getSelectedEntities(context: Context): Set<String> =
getPrefs(context).getStringSet(KEY_SELECTED_ENTITIES, emptySet()) ?: emptySet()
fun setSelectedEntities(context: Context, entities: Set<String>) =
getPrefs(context).edit().putStringSet(KEY_SELECTED_ENTITIES, entities).apply()
}

View File

@@ -0,0 +1,45 @@
package com.example.retroha.i18n
import android.content.Context
import com.example.retroha.R
class AndroidStrings(private val context: Context) : Strings {
override fun get(key: StringKey): String = context.getString(
when (key) {
StringKey.STATE_ON -> R.string.state_on
StringKey.STATE_OFF -> R.string.state_off
StringKey.STATE_UNAVAILABLE -> R.string.state_unavailable
StringKey.STATE_TOGGLING -> R.string.state_toggling
StringKey.BTN_SETTINGS -> R.string.btn_settings
StringKey.TOAST_WIDGET_ADD -> R.string.toast_widget_add
StringKey.TAB_ALL -> R.string.tab_all
StringKey.TAB_LIGHTING -> R.string.tab_lighting
StringKey.TAB_SOCKETS -> R.string.tab_sockets
StringKey.TAB_POWER -> R.string.tab_power
StringKey.TAB_WEATHER -> R.string.tab_weather
StringKey.TITLE_SETTINGS -> R.string.title_settings
StringKey.TITLE_CONNECTION -> R.string.title_connection
StringKey.TITLE_INSTRUCTIONS -> R.string.title_instructions
StringKey.INSTRUCTION_1 -> R.string.instruction_1
StringKey.INSTRUCTION_2 -> R.string.instruction_2
StringKey.INSTRUCTION_3 -> R.string.instruction_3
StringKey.INSTRUCTION_4 -> R.string.instruction_4
StringKey.INSTRUCTION_5 -> R.string.instruction_5
StringKey.LABEL_URL -> R.string.label_url
StringKey.LABEL_TOKEN -> R.string.label_token
StringKey.LABEL_REFRESH -> R.string.label_refresh
StringKey.BTN_TEST_SAVE -> R.string.btn_test_save
StringKey.BTN_DELETE_ALL -> R.string.btn_delete_all
StringKey.BTN_SAVE_SELECTED -> R.string.btn_save_selected
StringKey.BTN_CHANGE_LANG -> R.string.btn_change_lang
StringKey.STATUS_CONNECTING -> R.string.status_connecting
StringKey.STATUS_CONNECTED -> R.string.status_connected
StringKey.STATUS_OFFLINE -> R.string.status_offline
StringKey.STATUS_ERROR_HA -> R.string.status_error_ha
StringKey.STATUS_NO_TOKEN -> R.string.status_no_token
StringKey.CONFIRM_DELETE_ALL -> R.string.confirm_delete_all
StringKey.CONFIRM_CHANGE_CONN -> R.string.confirm_change_conn
StringKey.DIALOG_BRIGHTNESS -> R.string.dialog_brightness
StringKey.DIALOG_USTAW -> R.string.dialog_ustaw
StringKey.DIALOG_ANULUJ -> R.string.dialog_anuluj
}
)
}

View File

@@ -0,0 +1,16 @@
package com.example.retroha.i18n
import android.content.Context
import android.content.res.Configuration
import com.example.retroha.data.Prefs
import java.util.Locale
object LocaleHelper {
fun setLocale(context: Context) {
val lang = Prefs.getLanguage(context) ?: return
val locale = Locale(lang)
Locale.setDefault(locale)
val resources = context.resources
val config = Configuration(resources.configuration)
config.locale = locale
resources.updateConfiguration(config, resources.displayMetrics)
}
}

View File

@@ -0,0 +1,28 @@
package com.example.retroha.network
import retrofit2.Call
import retrofit2.http.*
interface HaApiService {
@GET("api/states")
fun getStates(): Call<List<HaState>>
@POST("api/services/{domain}/toggle")
fun toggle(@Path("domain") domain: String, @Body body: ToggleRequest): Call<List<HaState>>
@POST("api/services/light/turn_on")
fun setBrightness(@Body body: BrightnessRequest): Call<List<HaState>>
}
data class HaState(
val entity_id: String,
val state: String,
val attributes: HaAttributes
)
data class HaAttributes(
val friendly_name: String?,
val unit_of_measurement: String?,
val brightness: Int? = null
)
data class ToggleRequest(
val entity_id: String
)
data class BrightnessRequest(
val entity_id: String,
val brightness: Int
)

View File

@@ -0,0 +1,42 @@
package com.example.retroha.network
import android.content.Context
import com.example.retroha.data.Prefs
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object HaClient {
private var serviceInstance: HaApiService? = null
private val gson = GsonBuilder()
.registerTypeAdapter(HaState::class.java, HaStateAdapter())
.create()
fun getService(context: Context): HaApiService {
val url = Prefs.getUrl(context)
val token = Prefs.getToken(context)
return serviceInstance ?: buildService(url, token).also { serviceInstance = it }
}
fun clearCache() {
serviceInstance = null
}
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()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("Content-Type", "application/json")
.build()
chain.proceed(request)
}
.build()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(HaApiService::class.java)
}
}

View File

@@ -0,0 +1,38 @@
package com.example.retroha.network
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
class HaStateAdapter : TypeAdapter<HaState>() {
override fun write(out: JsonWriter, value: HaState?) {
}
override fun read(reader: JsonReader): HaState {
var entityId = ""
var state = ""
var friendlyName: String? = null
var unit: String? = null
var brightness: Int? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"entity_id" -> entityId = reader.nextString()
"state" -> state = reader.nextString()
"attributes" -> {
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"friendly_name" -> friendlyName = if (reader.peek() == JsonToken.NULL) { reader.nextNull(); null } else reader.nextString()
"unit_of_measurement" -> unit = if (reader.peek() == JsonToken.NULL) { reader.nextNull(); null } else reader.nextString()
"brightness" -> brightness = if (reader.peek() == JsonToken.NULL) { reader.nextNull(); null } else reader.nextInt()
else -> reader.skipValue()
}
}
reader.endObject()
}
else -> reader.skipValue()
}
}
reader.endObject()
return HaState(entityId, state, HaAttributes(friendlyName, unit, brightness))
}
}

View File

@@ -0,0 +1,32 @@
package com.example.retroha.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.view.View
import com.example.retroha.theme.Colors
class BauhausCheckbox(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
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
paint.color = Colors.BLACK
paint.style = Paint.Style.STROKE
paint.strokeWidth = b
canvas.drawRect(b/2, b/2, size - b/2, size - b/2, paint)
if (isChecked) {
paint.style = Paint.Style.FILL
paint.color = Colors.YELLOW
canvas.drawRect(b * 1.5f, b * 1.5f, size - b * 1.5f, size - b * 1.5f, paint)
}
}
}

View File

@@ -0,0 +1,56 @@
package com.example.retroha.ui
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.LinearLayout
import android.widget.TextView
import com.example.retroha.network.HaState
import com.example.retroha.theme.Colors
class EntitySelectionAdapter(
private val context: Context,
private val items: List<HaState>,
private val selectedEntities: Set<String>
) : BaseAdapter() {
override fun getCount() = items.size
override fun getItem(position: Int) = items[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val layout = (convertView as? LinearLayout) ?: createLayout()
val checkbox = layout.getChildAt(0) as BauhausCheckbox
val textContainer = layout.getChildAt(1) as LinearLayout
val tvName = textContainer.getChildAt(0) as TextView
val tvId = textContainer.getChildAt(1) as TextView
val item = items[position]
checkbox.isChecked = selectedEntities.contains(item.entity_id)
tvName.text = item.attributes.friendly_name ?: item.entity_id
tvId.text = item.entity_id
return layout
}
private fun createLayout(): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(dp(12), dp(12), dp(12), dp(12))
gravity = Gravity.CENTER_VERTICAL
addView(BauhausCheckbox(context))
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(12), 0, 0, 0)
addView(TextView(context).apply {
typeface = android.graphics.Typeface.MONOSPACE
setTextColor(Colors.BLACK)
textSize = 14f
setSingleLine()
})
addView(TextView(context).apply {
typeface = android.graphics.Typeface.MONOSPACE
setTextColor(Colors.GRAY_MID)
textSize = 10f
setSingleLine()
})
})
}
}
private fun dp(v: Int) = (v * context.resources.displayMetrics.density + 0.5f).toInt()
}

View File

@@ -0,0 +1,6 @@
package com.example.retroha.ui
import android.graphics.Typeface
object Fonts {
val REGULAR: Typeface by lazy { Typeface.create("monospace", Typeface.NORMAL) }
val BOLD: Typeface by lazy { Typeface.create("monospace", Typeface.BOLD) }
}

View File

@@ -0,0 +1,74 @@
package com.example.retroha.ui
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import com.example.retroha.theme.Colors
object HaIcons {
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f
}
private val tmpRect = RectF()
private val tmpPath = Path()
fun draw(canvas: Canvas, domain: String, x: Float, y: Float, size: Float, color: Int) {
fillPaint.color = color
strokePaint.color = color
canvas.save()
canvas.translate(x, y)
when (domain) {
"light" -> drawLight(canvas, size)
"switch" -> drawSwitch(canvas, size)
"weather" -> drawWeather(canvas, size)
"power" -> drawPower(canvas, size)
"sensor" -> drawSensor(canvas, size)
else -> drawGeneric(canvas, size)
}
canvas.restore()
}
private fun drawLight(canvas: Canvas, s: Float) {
canvas.drawCircle(s / 2f, s / 2f, s * 0.2f, fillPaint)
val rayStart = s * 0.25f
val rayEnd = s * 0.5f
for (i in 0 until 8) {
val angle = i * 45.0
val startX = (s / 2f + rayStart * Math.cos(Math.toRadians(angle))).toFloat()
val startY = (s / 2f + rayStart * Math.sin(Math.toRadians(angle))).toFloat()
val endX = (s / 2f + rayEnd * Math.cos(Math.toRadians(angle))).toFloat()
val endY = (s / 2f + rayEnd * Math.sin(Math.toRadians(angle))).toFloat()
canvas.drawLine(startX, startY, endX, endY, strokePaint)
}
}
private fun drawSwitch(canvas: Canvas, s: Float) {
tmpPath.reset()
tmpPath.moveTo(s / 2f, s * 0.15f)
tmpPath.lineTo(s * 0.85f, s * 0.85f)
tmpPath.lineTo(s * 0.15f, s * 0.85f)
tmpPath.close()
canvas.drawPath(tmpPath, fillPaint)
}
private fun drawPower(canvas: Canvas, s: Float) {
tmpPath.reset()
tmpPath.moveTo(s * 0.15f, s * 0.15f)
tmpPath.lineTo(s * 0.85f, s / 2f)
tmpPath.lineTo(s * 0.15f, s * 0.85f)
tmpPath.close()
canvas.drawPath(tmpPath, fillPaint)
}
private fun drawWeather(canvas: Canvas, s: Float) {
canvas.drawCircle(s / 2f, s * 0.45f, s * 0.25f, fillPaint)
canvas.drawLine(s * 0.15f, s * 0.75f, s * 0.85f, s * 0.75f, strokePaint)
}
private fun drawSensor(canvas: Canvas, s: Float) {
canvas.drawCircle(s / 2f, s * 0.25f, s * 0.08f, fillPaint)
canvas.drawCircle(s / 2f, s * 0.5f, s * 0.12f, fillPaint)
canvas.drawCircle(s / 2f, s * 0.75f, s * 0.08f, fillPaint)
}
private fun drawGeneric(canvas: Canvas, s: Float) {
tmpRect.set(s * 0.25f, s * 0.25f, s * 0.75f, s * 0.75f)
canvas.drawRect(tmpRect, fillPaint)
}
}

View File

@@ -0,0 +1,28 @@
package com.example.retroha.ui
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import com.example.retroha.theme.Colors
class LanguageIconDrawable(private val sizePx: Int) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f
color = Colors.BLACK
}
override fun draw(canvas: Canvas) {
val cx = bounds.exactCenterX()
val cy = bounds.exactCenterY()
val r = sizePx / 2f - 2f
canvas.drawCircle(cx, cy, r, paint)
canvas.drawLine(cx - r, cy, cx + r, cy, paint)
canvas.drawLine(cx, cy - r, cx, cy + r, paint)
}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
}

View File

@@ -0,0 +1,75 @@
package com.example.retroha.ui
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.Window
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
import com.example.retroha.theme.Colors
class LightControlDialog(
context: Context,
private val entityName: String,
private val initialBrightness: Int,
private val onBrightnessChanged: (Int) -> Unit
) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
val density = context.resources.displayMetrics.density
fun dp(v: Int) = (v * density + 0.5f).toInt()
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Colors.WHITE)
setPadding(dp(24), dp(24), dp(24), dp(24))
layoutParams = LinearLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(TextView(context).apply {
text = entityName.uppercase()
typeface = android.graphics.Typeface.MONOSPACE
textSize = 16f
setTextColor(Colors.BLACK)
setPadding(0, 0, 0, dp(16))
})
root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(2))
setBackgroundColor(Colors.BLACK)
})
val tvBrightness = TextView(context).apply {
text = "JASNOŚĆ: ${(initialBrightness * 100 / 255)}%"
typeface = android.graphics.Typeface.MONOSPACE
textSize = 14f
setTextColor(Colors.BLACK)
setPadding(0, dp(24), 0, dp(8))
}
root.addView(tvBrightness)
val seekBar = SeekBar(context).apply {
max = 255
progress = initialBrightness
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(s: SeekBar?, p: Int, fromUser: Boolean) {
tvBrightness.text = "JASNOŚĆ: ${(p * 100 / 255)}%"
}
override fun onStartTrackingTouch(s: SeekBar?) {}
override fun onStopTrackingTouch(s: SeekBar?) {
onBrightnessChanged(progress)
}
})
}
root.addView(seekBar)
root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(1, dp(16))
})
setContentView(root)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
setCanceledOnTouchOutside(true)
}
}

View File

@@ -0,0 +1,33 @@
package com.example.retroha.ui
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import com.example.retroha.model.WidgetConfig
class WidgetAdapter(
private val context: Context,
initialItems: List<WidgetConfig>
) : BaseAdapter() {
private val items = mutableListOf<WidgetConfig>().apply { addAll(initialItems) }
var onToggle: ((WidgetConfig) -> Unit)? = null
var onLongToggle: ((WidgetConfig) -> Unit)? = null
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
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val card = convertView as? WidgetCardView ?: WidgetCardView(context).also {
it.onToggle = onToggle
it.onLongToggle = onLongToggle
}
if (position < items.size) {
card.bind(items[position])
}
return card
}
}

View File

@@ -0,0 +1,208 @@
package com.example.retroha.ui
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import android.view.View
import android.util.TypedValue
import android.view.animation.DecelerateInterpolator
import com.example.retroha.model.EntityState
import com.example.retroha.model.WidgetConfig
import com.example.retroha.model.WidgetInteraction
import com.example.retroha.model.toWidgetInteraction
import com.example.retroha.theme.Colors
class WidgetCardView(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val borderPx = dp(2)
private val stripePx = dp(4)
private val shadowPx = dp(3)
private val cardHeightPx = dp(88)
private val textLeft = borderPx + stripePx + dp(6)
private val textRight = dp(8)
private val textTop = dp(12)
private val lineGap1 = dp(3)
private val lineGap2 = dp(2)
private val execIconSize = dp(7).toFloat()
private val execIconPath = Path()
private val paintFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val tpLabel = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Colors.GRAY_MID; textSize = sp(11); typeface = Fonts.REGULAR
}
private val tpValue = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Colors.BLACK; textSize = sp(18); typeface = Fonts.BOLD
}
private val tpSecondary = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Colors.GRAY_MID; textSize = sp(10); typeface = Fonts.REGULAR
}
private val pulseInterpolator = DecelerateInterpolator()
var onToggle: ((WidgetConfig) -> Unit)? = null
var onLongToggle: ((WidgetConfig) -> Unit)? = null
private var config: WidgetConfig? = null
private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY
private var lLabel: StaticLayout? = null
private var lValue: StaticLayout? = null
private var lSecondary: StaticLayout? = null
private var pulseAnim: ObjectAnimator? = null
private var cachedLabel = ""
private var cachedValue = ""
private var cachedSecondary = ""
private var cachedTextW = 0
init {
isFocusable = true
setOnClickListener {
val cfg = config ?: return@setOnClickListener
onToggle?.invoke(cfg)
}
setOnLongClickListener {
val cfg = config ?: return@setOnLongClickListener false
onLongToggle?.invoke(cfg)
true
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec)
setMeasuredDimension(w, cardHeightPx + shadowPx)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateExecIconPath(w - shadowPx)
rebuildLayoutsIfNeeded(w)
}
fun bind(cfg: WidgetConfig) {
config = cfg
currentInteraction = cfg.domain.toWidgetInteraction()
isClickable = currentInteraction != WidgetInteraction.READ_ONLY
&& cfg.state != EntityState.UNAVAILABLE
&& cfg.state != EntityState.TOGGLING
tpValue.color = if (cfg.state == EntityState.UNAVAILABLE) Colors.GRAY_MID else Colors.BLACK
if (cfg.state == EntityState.TOGGLING) startPulse() else stopPulse()
if (width > 0) rebuildLayoutsIfNeeded(width)
invalidate()
}
private fun updateExecIconPath(cardW: Int) {
val sz = execIconSize
val right = cardW.toFloat() - dp(8)
val top = dp(9).toFloat()
execIconPath.reset()
execIconPath.moveTo(right - sz, top)
execIconPath.lineTo(right, top + sz / 2f)
execIconPath.lineTo(right - sz, top + sz)
execIconPath.close()
}
private fun rebuildLayoutsIfNeeded(viewW: Int) {
val cfg = config ?: return
val cardW = viewW - shadowPx
val textW = (cardW - textLeft - textRight).coerceAtLeast(1)
val labelChanged = cfg.label != cachedLabel
val textChanged = labelChanged || cfg.value != cachedValue || cfg.secondary != cachedSecondary
val widthChanged = textW != cachedTextW
if (!textChanged && !widthChanged && lLabel != null) return
val labelUp = if (labelChanged) cfg.label.uppercase() else cachedLabel
lLabel = makeLayout(labelUp, tpLabel, textW)
lValue = makeLayout(cfg.value, tpValue, textW)
lSecondary = makeLayout(cfg.secondary, tpSecondary, textW)
cachedLabel = cfg.label
cachedValue = cfg.value
cachedSecondary = cfg.secondary
cachedTextW = textW
}
override fun onDraw(canvas: Canvas) {
val cfg = config ?: return
val cardW = (width - shadowPx).toFloat()
val cardH = cardHeightPx.toFloat()
val b = borderPx.toFloat()
val s = shadowPx.toFloat()
paintFill.color = Colors.BLACK
canvas.drawRect(s, s, width.toFloat(), cardH + s, paintFill)
paintFill.color = when (cfg.state) {
EntityState.TOGGLING -> Colors.BORDER_TOGGLING
EntityState.UNAVAILABLE -> Colors.BORDER_UNAVAILABLE
else -> Colors.BORDER_DEFAULT
}
canvas.drawRect(0f, 0f, cardW, cardH, paintFill)
paintFill.color = when (cfg.state) {
EntityState.ON -> Colors.STATUS_ON
EntityState.OFF -> Colors.STATUS_OFF
EntityState.UNAVAILABLE -> Colors.STATUS_UNAVAILABLE
EntityState.TOGGLING -> Colors.STATUS_TOGGLING
}
canvas.drawRect(b, b, cardW - b, cardH - b, paintFill)
paintFill.color = stripeColor(cfg.domain)
canvas.drawRect(b, b, b + stripePx, cardH - b, paintFill)
val iconSize = dp(16).toFloat()
val iconX = cardW - iconSize - dp(8)
val iconY = dp(8).toFloat()
HaIcons.draw(canvas, cfg.domain, iconX, iconY, iconSize, paintFill.color)
if (currentInteraction == WidgetInteraction.EXECUTE && cfg.state != EntityState.UNAVAILABLE) {
paintFill.color = Colors.GRAY_MID
canvas.drawPath(execIconPath, paintFill)
}
val tl = lLabel; val tv = lValue; val ts = lSecondary
if (tl != null && tv != null && ts != null) {
canvas.save()
canvas.translate(textLeft.toFloat(), textTop.toFloat())
tl.draw(canvas)
canvas.translate(0f, (tl.height + lineGap1).toFloat())
tv.draw(canvas)
canvas.translate(0f, (tv.height + lineGap2).toFloat())
ts.draw(canvas)
canvas.restore()
}
if (isPressed) {
paintFill.color = 0x22000000
canvas.drawRect(0f, 0f, cardW, cardH, paintFill)
}
}
override fun drawableStateChanged() {
super.drawableStateChanged()
invalidate()
}
private fun startPulse() {
if (pulseAnim?.isRunning == true) return
setLayerType(LAYER_TYPE_HARDWARE, null)
pulseAnim = ObjectAnimator.ofFloat(this, ALPHA, 1f, 0.35f).apply {
duration = 550
repeatMode = ObjectAnimator.REVERSE
repeatCount = ObjectAnimator.INFINITE
interpolator = pulseInterpolator
start()
}
}
private fun stopPulse() {
pulseAnim?.cancel()
pulseAnim = null
setLayerType(LAYER_TYPE_NONE, null)
alpha = 1f
}
private fun stripeColor(domain: String): Int = when (domain) {
"light" -> Colors.STRIPE_LIGHT
"switch" -> Colors.STRIPE_SWITCH
"sensor" -> Colors.STRIPE_SENSOR
"binary_sensor" -> Colors.STRIPE_BINARY_SENSOR
"script" -> Colors.STRIPE_SCRIPT
"automation" -> Colors.STRIPE_AUTOMATION
else -> Colors.STRIPE_DEFAULT
}
private fun dp(v: Int) = (v * density + 0.5f).toInt()
private fun sp(v: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, v.toFloat(), resources.displayMetrics)
@Suppress("DEPRECATION")
private fun makeLayout(text: String, paint: TextPaint, width: Int): StaticLayout =
if (Build.VERSION.SDK_INT >= 23)
StaticLayout.Builder
.obtain(text, 0, text.length, paint, width)
.setMaxLines(1)
.setEllipsize(TextUtils.TruncateAt.END)
.build()
else
StaticLayout(
text, 0, text.length, paint, width,
Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false,
TextUtils.TruncateAt.END, width
)
}

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FF000000" />
<size android:width="2dp" />
</shape>

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/ha_white"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_connection"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="18sp"
android:textColor="@color/ha_black" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/ha_black"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_url"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black" />
<EditText
android:id="@+id/etUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:hint="http://10.0.2.2:8123"
android:inputType="textUri"
android:typeface="monospace"
android:textSize="16sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/ha_black"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_token"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black" />
<EditText
android:id="@+id/etToken"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:hint="••••••••••••"
android:inputType="textPassword"
android:typeface="monospace"
android:textSize="16sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/ha_black"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_refresh"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black" />
<EditText
android:id="@+id/etRefreshInterval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:hint="30"
android:inputType="number"
android:typeface="monospace"
android:textSize="16sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/ha_black"
android:layout_marginBottom="24dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnTestAndSave"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_test_save"
android:background="@color/ha_blue"
android:textColor="@color/ha_white"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:typeface="monospace"
android:textSize="12sp"
android:gravity="center"
android:textColor="@color/ha_black" />
</LinearLayout>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/ha_white"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_entity_selection"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="18sp"
android:textColor="@color/ha_black" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/ha_black"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Szukaj encji..."
android:inputType="text"
android:typeface="monospace"
android:textSize="14sp"
android:drawableLeft="@android:drawable/ic_menu_search" />
<ListView
android:id="@+id/lvEntities"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:divider="@color/ha_black"
android:dividerHeight="1dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_save_selected"
android:background="@color/ha_blue"
android:textColor="@color/ha_white"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/ha_white">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_instructions"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="18sp"
android:textColor="@color/ha_black"
android:layout_marginBottom="16dp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/ha_black"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_header_1"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="14sp"
android:textColor="@color/ha_black"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_body_1"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_header_2"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="14sp"
android:textColor="@color/ha_black"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_body_2"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_header_3"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="14sp"
android:textColor="@color/ha_black"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/inst_body_3"
android:typeface="monospace"
android:textSize="12sp"
android:textColor="@color/ha_black"
android:lineSpacingExtra="4dp"
android:layout_marginBottom="24dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/ha_white">
<!-- TOP BAR -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/ha_white">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="54dp"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_title"
android:textSize="18sp"
android:textStyle="bold"
android:typeface="monospace"
android:textColor="@color/ha_black" />
<TextView
android:id="@+id/tvStatusIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ONLINE"
android:textSize="9sp"
android:textStyle="bold"
android:typeface="monospace"
android:textColor="@color/ha_blue" />
</LinearLayout>
<FrameLayout
android:id="@+id/btnSettingsContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true">
<!-- hard shadow -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="USTAWIENIA"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:layout_marginLeft="3dp"
android:layout_marginTop="3dp"
android:background="@color/ha_black"
android:textSize="12sp"
android:typeface="monospace"
android:textColor="@color/ha_black" />
<!-- button -->
<TextView
android:id="@+id/btnSettings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="USTAWIENIA"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:background="@color/ha_blue"
android:textSize="12sp"
android:typeface="monospace"
android:textColor="@color/ha_white"
android:clickable="true" />
</FrameLayout>
</RelativeLayout>
<!-- TABS -->
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
android:background="@color/ha_white">
<LinearLayout
android:id="@+id/tabContainer"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:orientation="horizontal"
android:paddingLeft="8dp"
android:paddingRight="8dp">
<!-- Tabs will be added programmatically -->
</LinearLayout>
</HorizontalScrollView>
<!-- 2dp bottom border -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/ha_black" />
</LinearLayout>
<!-- WIDGET GRID -->
<GridView
android:id="@+id/gridView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:numColumns="2"
android:horizontalSpacing="8dp"
android:verticalSpacing="8dp"
android:padding="8dp"
android:background="@color/ha_white"
android:listSelector="@android:color/transparent"
android:scrollbarStyle="insideOverlay"
android:scrollbars="vertical"
android:clipToPadding="false" />
</LinearLayout>

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/ha_white"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_settings"
android:textStyle="bold"
android:typeface="monospace"
android:textSize="18sp"
android:textColor="@color/ha_black"
android:layout_marginBottom="16dp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/ha_black"
android:layout_marginBottom="16dp" />
<!-- ENTITY SELECTION BUTTON -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnEntitySelection"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_entity_selection"
android:background="@color/ha_yellow"
android:textColor="@color/ha_black"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
<!-- CONNECTION BUTTON -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnGoToConnection"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/title_connection"
android:background="@color/ha_blue"
android:textColor="@color/ha_white"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
<!-- INSTRUCTIONS BUTTON -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnInstructions"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_instructions"
android:background="@color/ha_gray_light"
android:textColor="@color/ha_black"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
<!-- CHANGE LANGUAGE BUTTON -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnChangeLang"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_change_lang"
android:background="@color/ha_orange"
android:textColor="@color/ha_black"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
<!-- DELETE ALL BUTTON -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<View
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/ha_black"
android:layout_marginLeft="4dp"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/btnDeleteAll"
android:layout_width="match_parent"
android:layout_height="50dp"
android:text="@string/btn_delete_all"
android:background="@color/ha_red"
android:textColor="@color/ha_white"
android:typeface="monospace"
android:textStyle="bold" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RetroHA</string>
<string name="app_title">HA_PANEL</string>
<string name="state_on">ON</string>
<string name="state_off">OFF</string>
<string name="state_unavailable">UNAVAILABLE</string>
<string name="state_toggling"></string>
<string name="btn_settings">SETTINGS</string>
<string name="toast_widget_add">Add widget — TODO</string>
<string name="tab_all">ALL</string>
<string name="tab_lighting">LIGHTING</string>
<string name="tab_sockets">SOCKETS</string>
<string name="tab_power">POWER</string>
<string name="tab_weather">WEATHER</string>
<string name="title_settings">ENTITY SELECTION</string>
<string name="title_connection">SERVER CONFIGURATION</string>
<string name="title_instructions">USER MANUAL</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_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_5">5. CONNECTION CHANGE CLEARS WIDGET LIST.</string>
<string name="label_url">URL ADDRESS</string>
<string name="label_token">ACCESS TOKEN</string>
<string name="label_refresh">REFRESH (SECONDS)</string>
<string name="btn_test_save">TEST AND SAVE</string>
<string name="btn_delete_all">DELETE ALL WIDGETS</string>
<string name="btn_save_selected">SAVE SELECTED</string>
<string name="btn_change_lang">JĘZYK / LANGUAGE</string>
<string name="btn_entity_selection">WIDGET SELECTION</string>
<string name="btn_instructions">USER MANUAL</string>
<string name="status_connecting">CONNECTING...</string>
<string name="status_connected">CONNECTED SUCCESSFULLY</string>
<string name="status_offline">OFFLINE</string>
<string name="status_error_ha">HA ERROR</string>
<string name="status_no_token">NO TOKEN</string>
<string name="confirm_delete_all">ARE YOU SURE YOU WANT TO DELETE ALL WIDGETS FROM HOME?</string>
<string name="confirm_change_conn">CHANGING ADDRESS OR TOKEN WILL DELETE ALL SELECTED WIDGETS. CONTINUE?</string>
<string name="dialog_brightness">BRIGHTNESS</string>
<string name="dialog_ustaw">SET</string>
<string name="dialog_anuluj">CANCEL</string>
<string name="inst_header_1">BASIC USAGE</string>
<string name="inst_body_1">- Short click on a tile toggles the device (ON/OFF).\n- Long press on a light tile opens a dedicated brightness slider.\n- Use the tabs at the top (e.g., Lighting, Power) to quickly filter displayed devices.</string>
<string name="inst_header_2">SORTING &amp; VISIBILITY</string>
<string name="inst_body_2">- Widgets are displayed and sorted based on their \'entity_id\' (identifiers from Home Assistant).\n- Currently supported domains: light, switch, power, sensor, weather.</string>
<string name="inst_header_3">OPTIMIZATION</string>
<string name="inst_body_3">- The app is built for older tablets. It uses a custom, lightweight graphics engine (Bauhaus Canvas).\n- KIOSK mode is enabled by default (the screen will never turn off while the app is visible).\n- Background refresh rate can be adjusted in \'Server Configuration\'.</string>
</resources>

View File

@@ -0,0 +1,11 @@
<resources>
<!-- Force light Bauhaus theme even in system dark mode -->
<style name="Theme.RetroHA" parent="@android:style/Theme.Holo.Light.NoActionBar">
<item name="android:windowBackground">@color/ha_white</item>
<item name="android:colorBackground">@color/ha_white</item>
<item name="android:textColor">@color/ha_black</item>
<item name="android:listDivider">@null</item>
<item name="android:listSelector">@android:color/transparent</item>
<item name="android:scrollbarThumbVertical">@drawable/scrollbar_thumb</item>
</style>
</resources>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Bauhaus primaries -->
<color name="ha_black">#FF000000</color>
<color name="ha_white">#FFFFFFFF</color>
<color name="ha_red">#FFE23A24</color>
<color name="ha_yellow">#FFFAD02C</color>
<color name="ha_blue">#FF0056B3</color>
<!-- Bauhaus secondaries -->
<color name="ha_orange">#FFF4801A</color>
<color name="ha_green">#FF2D7D46</color>
<color name="ha_violet">#FF6B3FA0</color>
<!-- Neutrals -->
<color name="ha_gray_light">#FFCCCCCC</color>
<color name="ha_gray_mid">#FF888888</color>
<color name="ha_gray_dark">#FF444444</color>
</resources>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RetroHA</string>
<string name="app_title">HA_PANEL</string>
<string name="state_on">WŁ.</string>
<string name="state_off">WYŁ.</string>
<string name="state_unavailable">NIEOSIĄGALNE</string>
<string name="state_toggling"></string>
<string name="btn_settings">USTAWIENIA</string>
<string name="toast_widget_add">Dodaj widget — TODO</string>
<string name="tab_all">WSZYSTKO</string>
<string name="tab_lighting">OŚWIETLENIE</string>
<string name="tab_sockets">GNIAZDKA</string>
<string name="tab_power">MOC</string>
<string name="tab_weather">POGODA</string>
<string name="title_settings">WYBÓR ENCJI</string>
<string name="title_connection">KONFIGURACJA SERWERA</string>
<string name="title_instructions">INSTRUKCJA OBSŁUGI</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_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_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>
<string name="label_refresh">ODŚWIEŻANIE (SEKUNDY)</string>
<string name="btn_test_save">TESTUJ I ZAPISZ</string>
<string name="btn_delete_all">USUŃ WSZYSTKIE WIDŻETY</string>
<string name="btn_save_selected">ZAPISZ WYBRANE</string>
<string name="btn_change_lang">JĘZYK / LANGUAGE</string>
<string name="btn_entity_selection">WYBÓR WIDŻETÓW</string>
<string name="btn_instructions">INSTRUKCJA OBSŁUGI</string>
<string name="status_connecting">ŁĄCZENIE...</string>
<string name="status_connected">POŁĄCZONO POMYŚLNIE</string>
<string name="status_offline">OFFLINE</string>
<string name="status_error_ha">BŁĄD HA</string>
<string name="status_no_token">BRAK TOKENU</string>
<string name="confirm_delete_all">CZY NA PEWNO CHCESZ USUNĄĆ WSZYSTKIE WIDŻETY Z PULPITU?</string>
<string name="confirm_change_conn">ZMIANA ADRESU LUB TOKENU SPOWODUJE USUNIĘCIE WSZYSTKICH WYBRANYCH WIDŻETÓW. KONTYNUOWAĆ?</string>
<string name="dialog_brightness">JASNOŚĆ</string>
<string name="dialog_ustaw">USTAW</string>
<string name="dialog_anuluj">ANULUJ</string>
<string name="inst_header_1">PODSTAWY OBSŁUGI</string>
<string name="inst_body_1">- Krótkie kliknięcie kafelka przełącza urządzenie (ON/OFF).\n- Długie przytrzymanie kafelka światła otwiera dedykowany suwak jasności.\n- Zakładki na górze ekranu (np. Oświetlenie, Moc) służą do szybkiego filtrowania wyświetlanych urządzeń.</string>
<string name="inst_header_2">SORTOWANIE I WIDOCZNOŚĆ</string>
<string name="inst_body_2">- Widżety są wyświetlane i sortowane w oparciu o ich \'entity_id\' (identyfikatory z Home Assistanta).\n- Aktualnie obsługiwane domeny: light, switch, power, sensor, weather.</string>
<string name="inst_header_3">OPTYMALIZACJA</string>
<string name="inst_body_3">- Aplikacja jest zbudowana z myślą o starych tabletach. Używa autorskiego, lekkiego silnika graficznego (Bauhaus Canvas).\n- Tryb KIOSK jest włączony domyślnie (ekran nigdy nie zgaśnie, gdy aplikacja jest widoczna).\n- Odświeżanie w tle można dostosować w \'Konfiguracji Połączenia\'.</string>
</resources>

View File

@@ -0,0 +1,10 @@
<resources>
<style name="Theme.RetroHA" parent="@android:style/Theme.Holo.Light.NoActionBar">
<item name="android:windowBackground">@color/ha_white</item>
<item name="android:colorBackground">@color/ha_white</item>
<item name="android:textColor">@color/ha_black</item>
<item name="android:listDivider">@null</item>
<item name="android:listSelector">@android:color/transparent</item>
<item name="android:scrollbarThumbVertical">@drawable/scrollbar_thumb</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

View File

@@ -0,0 +1,9 @@
package com.example.retroha
import org.junit.Test
import org.junit.Assert.*
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

15
build.gradle.kts Normal file
View File

@@ -0,0 +1,15 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.dokka)
}
// Global Dokka configuration for Wiki
subprojects {
plugins.withType<org.jetbrains.kotlin.dokka.gradle.DokkaPlugin> {
tasks.withType<org.jetbrains.kotlin.dokka.gradle.AbstractDokkaTask>().configureEach {
val format = name.substringAfter("dokka").substringBefore("MultiModule").lowercase()
outputDirectory.set(file("${project.rootDir}/docs/wiki/$format/${project.name}"))
}
}
}

15
gradle.properties Normal file
View File

@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View File

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

36
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,36 @@
[versions]
agp = "9.2.1"
kotlin = "2.0.21"
dokka = "2.0.0"
multidex = "2.0.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
okhttp = "3.12.13"
retrofit = "2.6.4"
gson = "2.10.1"
mpandroidchart = "v3.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version.ref = "mpandroidchart" }
multidex = { group = "androidx.multidex", name = "multidex", version.ref = "multidex" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
dokka = { id = "org.jetbrains.kotlin.dokka", version.ref = "dokka" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
#Sun May 17 20:54:36 CEST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

28
settings.gradle.kts Normal file
View File

@@ -0,0 +1,28 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "RetroHA"
include(":app")
include(":shared")

8
shared/build.gradle.kts Normal file
View File

@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.dokka)
}
kotlin {
jvmToolchain(11)
}

View File

@@ -0,0 +1,39 @@
package com.example.retroha.i18n
enum class StringKey {
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_TOGGLING,
BTN_SETTINGS,
TOAST_WIDGET_ADD,
TAB_ALL,
TAB_LIGHTING,
TAB_SOCKETS,
TAB_POWER,
TAB_WEATHER,
TITLE_SETTINGS,
TITLE_CONNECTION,
TITLE_INSTRUCTIONS,
INSTRUCTION_1,
INSTRUCTION_2,
INSTRUCTION_3,
INSTRUCTION_4,
INSTRUCTION_5,
LABEL_URL,
LABEL_TOKEN,
LABEL_REFRESH,
BTN_TEST_SAVE,
BTN_DELETE_ALL,
BTN_SAVE_SELECTED,
BTN_CHANGE_LANG,
STATUS_CONNECTING,
STATUS_CONNECTED,
STATUS_OFFLINE,
STATUS_ERROR_HA,
STATUS_NO_TOKEN,
CONFIRM_DELETE_ALL,
CONFIRM_CHANGE_CONN,
DIALOG_BRIGHTNESS,
DIALOG_USTAW,
DIALOG_ANULUJ
}

View File

@@ -0,0 +1,4 @@
package com.example.retroha.i18n
interface Strings {
operator fun get(key: StringKey): String
}

View File

@@ -0,0 +1,2 @@
package com.example.retroha.model
enum class EntityState { ON, OFF, UNAVAILABLE, TOGGLING }

View File

@@ -0,0 +1,11 @@
package com.example.retroha.model
data class WidgetConfig(
val id: Long,
val entityId: String,
val label: String,
val value: String,
val secondary: String,
val domain: String,
val state: EntityState,
val brightness: Int? = null
)

View File

@@ -0,0 +1,15 @@
package com.example.retroha.model
enum class WidgetInteraction { TOGGLE, EXECUTE, READ_ONLY }
fun String.toWidgetInteraction(): WidgetInteraction = when (this) {
"light",
"switch",
"fan",
"input_boolean",
"automation",
"lock" -> WidgetInteraction.TOGGLE
"script",
"scene",
"button",
"input_button" -> WidgetInteraction.EXECUTE
else -> WidgetInteraction.READ_ONLY
}

View File

@@ -0,0 +1,28 @@
package com.example.retroha.theme
object Colors {
const val BLACK = 0xFF000000.toInt()
const val WHITE = 0xFFFFFFFF.toInt()
const val RED = 0xFFE23A24.toInt()
const val YELLOW = 0xFFFAD02C.toInt()
const val BLUE = 0xFF0056B3.toInt()
const val ORANGE = 0xFFF4801A.toInt()
const val GREEN = 0xFF2D7D46.toInt()
const val VIOLET = 0xFF6B3FA0.toInt()
const val GRAY_LIGHT = 0xFFCCCCCC.toInt()
const val GRAY_MID = 0xFF888888.toInt()
const val GRAY_DARK = 0xFF444444.toInt()
const val STATUS_ON = YELLOW
const val STATUS_OFF = WHITE
const val STATUS_UNAVAILABLE = GRAY_LIGHT
const val STATUS_TOGGLING = WHITE
const val BORDER_DEFAULT = BLACK
const val BORDER_TOGGLING = BLUE
const val BORDER_UNAVAILABLE = GRAY_MID
const val STRIPE_LIGHT = ORANGE
const val STRIPE_SWITCH = BLUE
const val STRIPE_SENSOR = VIOLET
const val STRIPE_BINARY_SENSOR = VIOLET
const val STRIPE_SCRIPT = RED
const val STRIPE_AUTOMATION = RED
const val STRIPE_DEFAULT = GRAY_DARK
}