├── .gitignore ├── LICENSE.txt ├── README.md ├── android ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── kotlin │ └── com │ │ └── displayer │ │ └── android │ │ ├── MainActivity.kt │ │ └── app │ │ └── DisplayerApp.kt │ └── res │ ├── drawable-xhdpi │ └── banner.png │ ├── drawable │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── ic_launcher_background.xml │ └── strings.xml ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts ├── settings.gradle └── src │ └── main │ └── kotlin │ └── AndroidSdk.kt ├── common ├── build.gradle.kts └── src │ ├── androidMain │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── com │ │ │ └── displayer │ │ │ └── platform │ │ │ └── Platform.kt │ └── res │ │ ├── drawable-nodpi │ │ ├── social_facebook.png │ │ ├── social_instagram.png │ │ ├── social_snapchat.png │ │ ├── social_tiktok.png │ │ ├── social_youtube_dark.png │ │ └── social_youtube_light.png │ │ └── drawable │ │ ├── logo_white.xml │ │ ├── severity_error.xml │ │ ├── severity_info.xml │ │ ├── severity_warning.xml │ │ ├── weather_cloudy.xml │ │ ├── weather_cloudy_snowing.xml │ │ ├── weather_fog.xml │ │ ├── weather_partly_cloudy.xml │ │ ├── weather_rainy.xml │ │ ├── weather_sunny.xml │ │ ├── weather_thunderstorm.xml │ │ └── wind_direction.xml │ ├── commonMain │ ├── i18n │ │ ├── Strings_en.properties │ │ └── Strings_nl.properties │ └── kotlin │ │ └── com │ │ └── displayer │ │ ├── Ext.kt │ │ ├── Koin.kt │ │ ├── admin │ │ ├── AdminPanel.kt │ │ ├── AdminServer.kt │ │ └── AdminState.kt │ │ ├── app │ │ ├── App.kt │ │ ├── AppState.kt │ │ ├── AppUi.kt │ │ └── InitializingScreen.kt │ │ ├── config │ │ ├── Config.kt │ │ ├── ConfigRepo.kt │ │ ├── Parameters.kt │ │ └── ParametersRepo.kt │ │ ├── display │ │ ├── DefaultDisplayFile.kt │ │ ├── Display.kt │ │ ├── DisplayRepo.kt │ │ ├── DisplayState.kt │ │ ├── DisplayUi.kt │ │ ├── ErrorUi.kt │ │ ├── MainUi.kt │ │ ├── container │ │ │ ├── ColumnLayout.kt │ │ │ ├── Container.kt │ │ │ ├── Empty.kt │ │ │ ├── RowLayout.kt │ │ │ └── Stacked.kt │ │ ├── item │ │ │ ├── Clock.kt │ │ │ ├── Drink.kt │ │ │ ├── Image.kt │ │ │ ├── Item.kt │ │ │ ├── RandomItem.kt │ │ │ ├── Social.kt │ │ │ ├── TextItem.kt │ │ │ ├── UnknownItem.kt │ │ │ └── Weather.kt │ │ └── parser │ │ │ ├── Dto.kt │ │ │ ├── Message.kt │ │ │ ├── Parser.kt │ │ │ ├── Result.kt │ │ │ └── Severity.kt │ │ ├── platform │ │ └── Platform.kt │ │ ├── ui │ │ ├── Icon.kt │ │ ├── Message.kt │ │ ├── Styling.kt │ │ └── Text.kt │ │ └── weather │ │ ├── WeatherData.kt │ │ ├── WeatherRepo.kt │ │ ├── WeatherState.kt │ │ └── api │ │ └── Model.kt │ ├── commonTest │ └── kotlin │ │ └── com │ │ └── displayer │ │ ├── ColorIsLightTest.kt │ │ └── ColorParseTest.kt │ ├── desktopMain │ ├── kotlin │ │ └── com │ │ │ └── displayer │ │ │ ├── app │ │ │ └── DesktopApp.kt │ │ │ └── platform │ │ │ └── Platform.kt │ └── resources │ │ ├── logo_white.svg │ │ ├── severity_error.svg │ │ ├── severity_info.svg │ │ ├── severity_warning.svg │ │ ├── social_facebook.png │ │ ├── social_instagram.png │ │ ├── social_snapchat.png │ │ ├── social_tiktok.png │ │ ├── social_youtube_dark.png │ │ ├── social_youtube_light.png │ │ ├── weather_cloudy.svg │ │ ├── weather_cloudy_snowing.svg │ │ ├── weather_fog.svg │ │ ├── weather_partly_cloudy.svg │ │ ├── weather_rainy.svg │ │ ├── weather_sunny.svg │ │ ├── weather_thunderstorm.svg │ │ └── wind_direction.svg │ ├── jsMain │ ├── kotlin │ │ └── com │ │ │ └── displayer │ │ │ ├── JsKoin.kt │ │ │ ├── admin │ │ │ └── NoopAdminServer.kt │ │ │ ├── browser │ │ │ ├── BrowserViewportWindow.kt │ │ │ └── Main.kt │ │ │ └── platform │ │ │ └── Platform.kt │ └── resources │ │ └── index.html │ └── jvmMain │ └── kotlin │ └── com │ └── displayer │ ├── JvmKoin.kt │ └── admin │ └── HttpAdminServer.kt ├── desktop ├── build.gradle.kts ├── icon.png └── src │ └── jvmMain │ └── kotlin │ └── Main.kt ├── docs ├── graphics │ └── feature-graphic.png ├── samples │ ├── images.json │ ├── images.png │ ├── index.md │ ├── locale_en_US.json │ ├── locale_en_US.png │ ├── locale_fi_FI.json │ ├── locale_fi_FI.png │ ├── locale_nl_BE.json │ ├── locale_nl_BE.png │ ├── not-json.png │ ├── not-json.txt │ ├── problems.json │ ├── problems.png │ ├── regions-alternative.json │ ├── regions-alternative.png │ ├── regions-images.json │ ├── regions.json │ ├── regions.png │ ├── socials.json │ ├── socials.png │ ├── styles-items.json │ ├── styles-items.png │ ├── styles-regions.json │ ├── styles-regions.png │ ├── text.json │ ├── text.png │ ├── unknown-types.json │ ├── unknown-types.png │ ├── weather_betekom_BE.json │ ├── weather_betekom_BE.png │ ├── weather_rochester_MN.json │ └── weather_rochester_MN.png └── screenshots │ ├── screenshot-1.png │ └── screenshot-2.png ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .idea 3 | local.properties 4 | *.iml 5 | **/build 6 | captures 7 | /kotlin-js-store 8 | .cache 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Displayer 3 | 4 | [![License](https://img.shields.io/github/license/litrik/displayer)](http://www.apache.org/licenses/LICENSE-2.0) 5 | [![Kotlin](https://img.shields.io/badge/kotlin-v1.7.20-blue.svg?logo=kotlin)](http://kotlinlang.org) 6 | [![Compose](https://img.shields.io/badge/compose-v1.2.1-blue)](http://kotlinlang.org) 7 | 8 | An open source multi-platform app for digital signage 9 | 10 | ![Feature graphic](docs/graphics/feature-graphic.png "Feature graphic") 11 | 12 | ## Table of contents 13 | 14 | * [Introduction](#introduction) 15 | * [Status](#status) 16 | * [Screenshots](#screenshots) 17 | * [Build and run](#build-and-run) 18 | * [Android](#android) 19 | * [Desktop](#desktop) 20 | * [Display file structure](#display-file-structure) 21 | * [Admin server](#admin-server) 22 | * [Start the admin server](#start-the-admin-server) 23 | * [Load a remote display file](#load-a-remote-display-file) 24 | * [Upload a local display file](#upload-a-local-display-file) 25 | * [Set OpenWeather API key](#set-openweather-api-key) 26 | * [Stop the admin server](#stop-the-admin-server) 27 | * [Android intents](#android-intents) 28 | * [Load a remote display file](#load-a-remote-display-file-1) 29 | * [Set OpenWeather API key](#set-openweather-api-key-1) 30 | * [Stop the admin server](#stop-the-admin-server-1) 31 | * [Android TV launcher](#android-tv-launcher) 32 | * [Built with](#built-with) 33 | * [License](#license) 34 | 35 | ## Introduction 36 | 37 | The _Displayer_ app shows your content, described in a JSON file, in an endless loop, without any intervention, on the device of your choice. 38 | 39 | Some of its features are: 40 | * Works on multiple platforms: 41 | * Android, including Android TV 42 | * Linux 43 | * Any other platform supported by Kotlin Multiplatform (KMP) 44 | * Multiple _regions_ 45 | * _Center_ region to show your main content 46 | * _Left_ and _Bottom_ region to show secondary (scrolling) content 47 | * Different types of content: 48 | * Text 49 | * Images 50 | * Clock 51 | * Live weather information (requires an OpenWeather API key) 52 | * Random content 53 | * Locale-aware (for time formatting, temperature units,...) 54 | * Is a _launcher_ on Android platforms (including Android TV) 55 | 56 | Displayer is being used successfully in the clubhouse of [korfbalclub KCBJ](https://www.kcbj.be/) to display information about upcoming events, sponsors, and live weather, without any intervention of the volunteer currently doing bar service. 57 | 58 | ## Status 59 | 60 | This project is currently under heavy development and should be considered _alpha-quality_ at best. 61 | 62 | ## Screenshots 63 | 64 | ![Screenshot](docs/screenshots/screenshot-1.png "Screenshot") 65 | ![Screenshot](docs/screenshots/screenshot-2.png "Screenshot") 66 | 67 | [![Watch screen recording](https://img.youtube.com/vi/r9NUL9Qbw-c/0.jpg)](https://www.youtube.com/watch?v=r9NUL9Qbw-c "Watch screen recording") 68 | 69 | ## Build and run 70 | 71 | There are currently no binary builds available. You have to download/clone the source code and build the app to run it. 72 | 73 | Building Displayer requires at minimum Java 17. 74 | 75 | Optionally, you can open the project in IntelliJ IDEA (tested with version 2023.3.3 of the Community Edition running on Java 17) 76 | 77 | ### Android 78 | 79 | * Define `sdk.dir` in `local.properties` to point to your Android SDK location 80 | * Execute `./gradlew assemble` to build the Android app 81 | * The resulting APK files will be placed in `android/build/outputs/apk` 82 | * Use [ADB](https://developer.android.com/studio/command-line/adb) to install the app on your Android device: `adb install android/build/outputs/apk/debug/android-debug.apk` 83 | 84 | ### Desktop 85 | 86 | * Execute `./gradlew package` to build the desktop apps 87 | * The resulting binaries will be placed in `desktop/build/compose/binaries` 88 | 89 | ## Display file structure 90 | 91 | There are [sample display files](docs/samples/index.md) available showcasing different features. 92 | 93 | A detailed explanation of the display file structure is coming soon... 94 | 95 | ## Admin server 96 | 97 | Displayer has a built-in webserver that can be used to send commands and control the app from a remote device. 98 | This server accepts commands sent via `curl`, `wget` or any web browser. 99 | 100 | The admin server is *not* enabled by default. 101 | 102 | ### Start the admin server 103 | 104 | #### Android 105 | 106 | On Android, you'll need [ADB](https://developer.android.com/studio/command-line/adb) to start the admin server with the following command: 107 | 108 | ``` 109 | adb shell am start -a com.displayer.action.CONFIG -e com.displayer.extra.ADMIN_PORT YOUR_PORT -e com.displayer.extra.ADMIN_SECRET YOUR_SECRET 110 | ``` 111 | 112 | Replace YOUR_PORT with the TCP/IP port number to use for the admin server. 113 | 114 | Replace YOUR_SECRET with a secret that must be included in any request to the admin port. 115 | 116 | #### Desktop 117 | 118 | On desktop, you must start Displayer with extra parameters to enable the admin server. 119 | 120 | Example: 121 | ``` 122 | /opt/displayer/bin/Displayer --admin-port=YOUR_PORT --admin-secret=YOUR_SECRET 123 | ``` 124 | 125 | Replace YOUR_PORT with the TCP/IP port number to use for the admin server. 126 | 127 | Replace YOUR_SECRET with a secret that must be included in any request to the admin port. 128 | 129 | ### Load a remote display file 130 | 131 | ``` 132 | curl "http://YOUR_HOST:YOUR_PORT/admin?secret=YOUR_SECRET&url=URL_OF_DISPLAY_FILE" 133 | ``` 134 | 135 | Replace URL_OF_DISPLAY_FILE with the URL of your own display file, e.g. https://example.com/displayer.json. 136 | Make sure you URL-encode all special characters in the URL. 137 | 138 | ### Upload a local display file 139 | 140 | ``` 141 | curl -F 'data=@PATH_OF_DISPLAY_FILE' "http://YOUR_HOST:YOUR_PORT/admin/display?secret=YOUR_SECRET" 142 | ``` 143 | 144 | Replace PATH_OF_DISPLAY_FILE with the path of a local display file that will be uploaded. 145 | 146 | ### Set OpenWeather API key 147 | 148 | ``` 149 | curl "http://YOUR_HOST:YOUR_PORT/admin?secret=YOUR_SECRET&open-weather-api-key=YOUR_API_KEY" 150 | ``` 151 | 152 | Replace YOUR_API_KEY with your own OpenWeather API key obtained from the [OpenWeather API console](https://home.openweathermap.org/api_keys). 153 | 154 | ### Stop the admin server 155 | 156 | ``` 157 | curl "http://YOUR_HOST:YOUR_PORT/admin?secret=YOUR_SECRET&kill-server" 158 | ``` 159 | 160 | ## Android intents 161 | 162 | The Android app also accepts a number of _intents with extras_. 163 | 164 | You can use [ADB](https://developer.android.com/studio/command-line/adb) to launch these intents. 165 | 166 | ### Load a remote display file 167 | ``` 168 | adb shell am start -a android.intent.action.VIEW -d "URL_OF_DISPLAY_FILE" 169 | ``` 170 | 171 | Replace URL_OF_DISPLAY_FILE with the URL of your own display file, e.g. https://example.com/displayer.json 172 | 173 | ### Set OpenWeather API key 174 | 175 | ``` 176 | adb shell am start -a com.displayer.action.CONFIG -e EXTRA_OPEN_WEATHER_API_KEY YOUR_API_KEY 177 | ``` 178 | 179 | Replace YOUR_API_KEY with your own OpenWeather API key obtained from the [OpenWeather API console](https://home.openweathermap.org/api_keys). 180 | 181 | ### Stop the admin server 182 | 183 | ``` 184 | adb shell am start -a com.displayer.action.KILL_SERVER 185 | ``` 186 | 187 | ## Android TV launcher 188 | 189 | After installing Displayer on Android TV, you can disable the original Android TV launcher. 190 | This will make sure that Displayer automatically starts after rebooting your Android TV device. 191 | 192 | To disable the original Android TV launcher enter the following commands: 193 | 194 | ``` 195 | adb shell pm disable-user --user 0 com.google.android.tvlauncher 196 | adb shell pm disable-user --user 0 com.google.android.tungsten.setupwraith 197 | ``` 198 | 199 | You can always re-enable the original Android TV launcher by entering the following commands: 200 | 201 | ``` 202 | adb shell pm enable com.google.android.tvlauncher 203 | adb shell pm enable com.google.android.tungsten.setupwraith 204 | ``` 205 | 206 | ## Built with 207 | 208 | * [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) 209 | * [Compose Desktop](https://www.jetbrains.com/lp/compose-desktop/) 210 | * [Kotlin Serialization](https://kotlinlang.org/docs/serialization.html) 211 | * [Kotlin Date/Time](https://github.com/Kotlin/kotlinx-datetime) 212 | * [Ktor](https://ktor.io/): HTTP server 213 | * [Koin](https://github.com/InsertKoinIO/koin): dependency injection framework 214 | * [Kamel](https://github.com/alialbaali/Kamel): asynchronous media loading library for Compose 215 | * [Multiplatform settings](https://github.com/russhwolf/multiplatform-settings): Kotlin Multiplatform library to persist key-value data 216 | * [Kermit](https://github.com/touchlab/Kermit): Kotlin Multiplatform logging 217 | 218 | # License 219 | 220 | Copyright 2022 Norio BV 221 | 222 | Licensed under the Apache License, Version 2.0 (the "License"); 223 | you may not use this file except in compliance with the License. 224 | You may obtain a copy of the License at 225 | 226 | http://www.apache.org/licenses/LICENSE-2.0 227 | 228 | Unless required by applicable law or agreed to in writing, software 229 | distributed under the License is distributed on an "AS IS" BASIS, 230 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 231 | See the License for the specific language governing permissions and 232 | limitations under the License. 233 | -------------------------------------------------------------------------------- /android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | id("org.jetbrains.compose") 5 | id("com.android.application") 6 | kotlin("android") 7 | } 8 | 9 | repositories { 10 | jcenter() 11 | } 12 | 13 | dependencies { 14 | implementation(project(":common")) 15 | implementation(libs.androidx.activity.compose) 16 | } 17 | 18 | fun isBuildServer() = System.getenv().containsKey("CI") || System.getenv().containsKey("BUILD_NUMBER") 19 | 20 | android { 21 | 22 | compileSdk = AndroidSdk.compile 23 | 24 | defaultConfig { 25 | applicationId = "com.displayer" 26 | namespace = "com.displayer.android" 27 | minSdk = AndroidSdk.min 28 | targetSdk = AndroidSdk.target 29 | versionCode = 1 30 | versionName = "0.1" 31 | 32 | resourceConfigurations += "en" 33 | 34 | if (isBuildServer()) { 35 | setProperty("archivesBaseName", "$namespace-$versionName-$versionCode") 36 | } 37 | } 38 | 39 | buildTypes { 40 | named("debug").configure { 41 | isMinifyEnabled = false 42 | } 43 | named("release").configure { 44 | isMinifyEnabled = false 45 | } 46 | } 47 | 48 | compileOptions { 49 | sourceCompatibility = JavaVersion.VERSION_17 50 | targetCompatibility = JavaVersion.VERSION_17 51 | } 52 | 53 | kotlinOptions { 54 | jvmTarget = JavaVersion.VERSION_17.toString() 55 | freeCompilerArgs = listOf( 56 | "-Xallow-unstable-dependencies" 57 | ) 58 | } 59 | 60 | lint { 61 | abortOnError = false 62 | } 63 | } -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 12 | 13 | 21 | 22 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /android/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/src/main/kotlin/com/displayer/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.android 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.View 6 | import android.view.WindowManager 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import androidx.compose.runtime.collectAsState 10 | import androidx.compose.runtime.getValue 11 | import androidx.lifecycle.lifecycleScope 12 | import co.touchlab.kermit.Logger 13 | import com.displayer.app.App 14 | import com.displayer.app.AppState 15 | import com.displayer.app.AppUi 16 | import org.koin.android.ext.android.inject 17 | 18 | class MainActivity : ComponentActivity() { 19 | 20 | private val app by inject() 21 | 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 25 | setContent { 26 | val display by app.observeState().collectAsState(AppState.Initializing) 27 | AppUi(display) 28 | } 29 | handleIntent(intent) 30 | } 31 | 32 | override fun onResume() { 33 | super.onResume() 34 | hideSystemUI() 35 | } 36 | 37 | override fun onNewIntent(intent: Intent?) { 38 | super.onNewIntent(intent) 39 | handleIntent(intent) 40 | } 41 | 42 | private fun handleIntent(intent: Intent?) { 43 | if (intent == null) { 44 | Logger.w { "Ignoring null Intent" } 45 | return 46 | } 47 | Logger.i { "Handling intent with action ${intent.action} and data ${intent.data}" } 48 | when (intent.action) { 49 | Intent.ACTION_MAIN -> lifecycleScope.launchWhenStarted { app.loadDisplayFromUrl(null) } 50 | Intent.ACTION_VIEW -> lifecycleScope.launchWhenStarted { app.loadDisplayFromUrl(intent.data?.toString()) } 51 | "com.displayer.action.KILL_SERVER" -> app.stopAdminServer() 52 | "com.displayer.action.CONFIG" -> intent.extras?.let { handleConfigIntent(it) } 53 | } 54 | } 55 | 56 | private fun handleConfigIntent(extras: Bundle) { 57 | Logger.d { "Handling config intent" } 58 | extras.getString("com.displayer.extra.OPEN_WEATHER_API_KEY")?.let { app.setOpenWeatherApiKey(it) } 59 | 60 | val extraPort: Int? = extras.getString("com.displayer.extra.ADMIN_PORT")?.toIntOrNull() 61 | val extraSecret: String? = extras.getString("com.displayer.extra.ADMIN_SECRET") 62 | app.setAdminParameters(extraPort, extraSecret) 63 | } 64 | 65 | private fun hideSystemUI() { 66 | window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE 67 | or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 68 | or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 69 | or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 70 | or View.SYSTEM_UI_FLAG_FULLSCREEN 71 | or View.SYSTEM_UI_FLAG_IMMERSIVE 72 | or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /android/src/main/kotlin/com/displayer/android/app/DisplayerApp.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.android.app 2 | 3 | import android.app.Application 4 | import com.displayer.coreModule 5 | import com.displayer.jvmModule 6 | import org.koin.android.ext.koin.androidContext 7 | import org.koin.android.ext.koin.androidLogger 8 | import org.koin.core.context.startKoin 9 | import org.koin.core.logger.Level 10 | 11 | class DisplayerApp : Application() { 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | startKoin { 16 | androidContext(this@DisplayerApp) 17 | modules(listOf(coreModule, jvmModule)) 18 | androidLogger(Level.INFO) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /android/src/main/res/drawable-xhdpi/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/drawable-xhdpi/banner.png -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1E9991 4 | -------------------------------------------------------------------------------- /android/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Displayer 3 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 6 | maven("https://maven.universablockchain.com/") 7 | } 8 | } 9 | 10 | plugins { 11 | kotlin("multiplatform") apply false 12 | kotlin("plugin.serialization") apply false 13 | kotlin("android") apply false 14 | id("com.android.application") apply false 15 | id("com.android.library") apply false 16 | id("org.jetbrains.compose") apply false 17 | } 18 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | google() 7 | mavenCentral() 8 | } 9 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | versionCatalogs { 3 | create("libs") { 4 | from(files("../gradle/libs.versions.toml")) 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/AndroidSdk.kt: -------------------------------------------------------------------------------- 1 | 2 | object AndroidSdk { 3 | const val min = 21 4 | const val compile = 34 5 | const val target = 34 6 | } 7 | -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING 4 | import java.time.OffsetDateTime 5 | import java.time.format.DateTimeFormatter; 6 | 7 | plugins { 8 | kotlin("multiplatform") 9 | kotlin("plugin.serialization") 10 | id("org.jetbrains.compose") 11 | id("com.android.library") 12 | id("de.comahe.i18n4k") version "0.7.0" 13 | id("com.codingfeline.buildkonfig") version "0.13.3" 14 | } 15 | 16 | group = "com.displayer" 17 | version = "0.1" 18 | 19 | i18n4k { 20 | sourceCodeLocales = listOf("en", "nl") 21 | } 22 | 23 | buildkonfig { 24 | packageName = "com.displayer" 25 | defaultConfigs { 26 | buildConfigField(STRING, "APP_VERSON_NAME", version as String) 27 | buildConfigField(STRING, "BUILD_TIME", DateTimeFormatter.ISO_INSTANT.format(OffsetDateTime.now()) as String) 28 | } 29 | } 30 | 31 | kotlin { 32 | android() 33 | jvm("desktop") 34 | 35 | sourceSets { 36 | val commonMain by getting { 37 | dependencies { 38 | 39 | api(compose.runtime) 40 | api(compose.foundation) 41 | 42 | implementation(libs.kotlinx.serialization.json) 43 | implementation(libs.kotlinx.collections.immutable) 44 | implementation(libs.kotlinx.datetime) 45 | 46 | implementation(libs.kamel.image) 47 | 48 | implementation(libs.ktor.client.content.negotiation) 49 | implementation(libs.ktor.serialization.kotlinx.json) 50 | 51 | implementation(libs.multiplatform.settings.no.arg) 52 | 53 | implementation(libs.koin.core) 54 | 55 | api(libs.kermit) 56 | 57 | implementation(libs.i18n4k.core) 58 | 59 | implementation(libs.mp.stools) 60 | 61 | implementation(libs.okio) 62 | } 63 | } 64 | val commonTest by getting { 65 | dependencies { 66 | implementation(kotlin("test")) 67 | } 68 | } 69 | val androidMain by getting { 70 | kotlin.srcDirs("src/jvmMain/kotlin") 71 | dependencies { 72 | api(libs.koin.android) 73 | implementation(libs.ktor.client.cio) 74 | implementation(libs.ktor.server.core) 75 | implementation(libs.ktor.server.cio) 76 | implementation(libs.slf4j.slf4j.nop) 77 | } 78 | } 79 | val androidUnitTest by getting { 80 | dependencies { 81 | implementation(libs.junit) 82 | } 83 | } 84 | val desktopMain by getting { 85 | kotlin.srcDirs("src/jvmMain/kotlin") 86 | dependencies { 87 | api(compose.preview) 88 | implementation(libs.ktor.client.cio) 89 | implementation(libs.ktor.server.core) 90 | implementation(libs.ktor.server.cio) 91 | implementation(libs.slf4j.slf4j.nop) 92 | } 93 | } 94 | val desktopTest by getting 95 | } 96 | } 97 | 98 | android { 99 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 100 | 101 | compileSdk = AndroidSdk.compile 102 | 103 | defaultConfig { 104 | minSdk = AndroidSdk.min 105 | namespace = "com.displayer" 106 | } 107 | 108 | compileOptions { 109 | sourceCompatibility = JavaVersion.VERSION_17 110 | targetCompatibility = JavaVersion.VERSION_17 111 | } 112 | 113 | buildFeatures { 114 | buildConfig = true 115 | } 116 | } 117 | 118 | compose.experimental { 119 | web.application {} 120 | } 121 | 122 | tasks.withType { 123 | kotlinOptions { 124 | freeCompilerArgs = freeCompilerArgs + listOf( 125 | "-opt-in=kotlin.RequiresOptIn" 126 | ) 127 | jvmTarget = JavaVersion.VERSION_17.toString() 128 | } 129 | } 130 | 131 | dependencies { 132 | implementation(libs.compose.ui) 133 | } 134 | -------------------------------------------------------------------------------- /common/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /common/src/androidMain/kotlin/com/displayer/platform/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.platform 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.graphics.painter.Painter 6 | import androidx.compose.ui.res.painterResource 7 | import androidx.compose.ui.text.intl.Locale 8 | import com.displayer.R 9 | import com.displayer.ui.Icon 10 | import kotlinx.datetime.Instant 11 | import okio.FileSystem 12 | import okio.Path 13 | import okio.Path.Companion.toPath 14 | import org.koin.java.KoinJavaComponent.get 15 | import java.io.File 16 | import java.text.SimpleDateFormat 17 | import java.util.* 18 | 19 | @Composable 20 | actual fun getIcon(icon: Icon): Painter = painterResource( 21 | when (icon) { 22 | Icon.Empty -> 0 23 | Icon.Logo -> R.drawable.logo_white 24 | Icon.SeverityInfo -> R.drawable.severity_info 25 | Icon.SeverityWarning -> R.drawable.severity_warning 26 | Icon.SeverityError -> R.drawable.severity_error 27 | Icon.WindDirection -> R.drawable.wind_direction 28 | Icon.WeatherSunny -> R.drawable.weather_sunny 29 | Icon.WeatherPartlyCloudy -> R.drawable.weather_partly_cloudy 30 | Icon.WeatherCloudy -> R.drawable.weather_cloudy 31 | Icon.WeatherRainy -> R.drawable.weather_rainy 32 | Icon.WeatherThunderstorm -> R.drawable.weather_thunderstorm 33 | Icon.WeatherCloudySnowing -> R.drawable.weather_cloudy_snowing 34 | Icon.WeatherFog -> R.drawable.weather_fog 35 | Icon.Facebook -> R.drawable.social_facebook 36 | Icon.Instagram -> R.drawable.social_instagram 37 | Icon.TikTok -> R.drawable.social_tiktok 38 | Icon.YouTubeDark -> R.drawable.social_youtube_dark 39 | Icon.YouTubeLight -> R.drawable.social_youtube_light 40 | Icon.Snapchat -> R.drawable.social_snapchat 41 | } 42 | ) 43 | 44 | actual fun formatTime(instant: Instant, locale: Locale): String { 45 | val df = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT, java.util.Locale.forLanguageTag(locale.toLanguageTag())) 46 | return df.format(Date(instant.toEpochMilliseconds())) 47 | } 48 | 49 | actual fun getDefaultLanguage() = Locale.current.language 50 | 51 | actual fun getDefaultCountry() = Locale.current.region 52 | 53 | actual fun getFilesystem(): FileSystem = FileSystem.SYSTEM 54 | 55 | actual fun getDisplayCachePath(): Path = File(get(Context::class.java).cacheDir,"display-cache.json").absolutePath.toPath() 56 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_facebook.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_instagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_instagram.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_snapchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_snapchat.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_tiktok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_tiktok.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_youtube_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_youtube_dark.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable-nodpi/social_youtube_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/litrik/displayer/cad2f8a574911233d1a14f190c0b03a5ff4e8ed5/common/src/androidMain/res/drawable-nodpi/social_youtube_light.png -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/logo_white.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/severity_error.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/severity_info.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/severity_warning.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_cloudy.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_cloudy_snowing.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_fog.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_partly_cloudy.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_rainy.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_sunny.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/weather_thunderstorm.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/androidMain/res/drawable/wind_direction.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/commonMain/i18n/Strings_en.properties: -------------------------------------------------------------------------------- 1 | adminLabelStatus=Status 2 | adminLabelUrl=URL 3 | adminLabelVersion=Version 4 | adminSectionApp=App 5 | adminSectionAdmin=Admin server 6 | adminLabelStopped=Stopped 7 | adminLabelRunning=Running on port {0} 8 | adminSectionDisplay=Display 9 | adminSectionWeather=Weather 10 | adminWeatherApiKeyInvalid=Invalid API key 11 | adminWeatherApiKeyNotSet=No API key set 12 | adminStatusFailure=Problem 13 | adminStatusSuccess=OK 14 | errorTitle=Something went wrong 15 | loading=Loading... 16 | windSpeedImperial=mph 17 | windSpeedMetric=km/h -------------------------------------------------------------------------------- /common/src/commonMain/i18n/Strings_nl.properties: -------------------------------------------------------------------------------- 1 | adminLabelStatus=Status 2 | adminLabelUrl=URL 3 | adminLabelVersion=Versie 4 | adminSectionApp=App 5 | adminSectionAdmin=Admin server 6 | adminLabelStopped=Gestopt 7 | adminLabelRunning=Actief op poort {0} 8 | adminSectionDisplay=Display 9 | adminSectionWeather=Weer 10 | adminWeatherApiKeyInvalid=Ongeldige API key 11 | adminWeatherApiKeyNotSet=API key niet ingesteld 12 | adminStatusFailure=Probleem 13 | adminStatusSuccess=OK 14 | errorTitle=Er ging iets fout 15 | loading=Laden... 16 | windSpeedImperial=mpu 17 | windSpeedMetric=km/u -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/Ext.kt: -------------------------------------------------------------------------------- 1 | package com.displayer 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.luminance 6 | import androidx.compose.ui.platform.LocalDensity 7 | import androidx.compose.ui.unit.Dp 8 | 9 | fun Color.Companion.parse(colorString: String): Color = 10 | with(colorString.removePrefix("#")) { 11 | try { 12 | when (length) { 13 | 8 -> { 14 | val color = (toLong(16) and 0xFFFFFF00) shr 8 15 | val alpha = toLong(16) and 0xFF 16 | Color(alpha shl 24 or color) 17 | } 18 | 6 -> Color(toLong(16) or 0x00000000FF000000) 19 | else -> Unspecified 20 | } 21 | } catch (e: Exception) { 22 | Unspecified 23 | } 24 | } 25 | 26 | fun Color.isLight(): Boolean = luminance() > 0.179f 27 | 28 | @Composable 29 | fun Dp.toSp() = with(LocalDensity.current) { this@toSp.toSp() } 30 | 31 | @Composable 32 | fun Dp.toPx(): Float = with(LocalDensity.current) { this@toPx.toPx() } 33 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.displayer 2 | 3 | import com.displayer.app.App 4 | import com.displayer.config.ConfigRepo 5 | import com.displayer.config.ParametersRepo 6 | import com.displayer.display.DisplayRepo 7 | import com.displayer.display.parser.ContainerDto 8 | import com.displayer.display.parser.ItemDto 9 | import com.displayer.display.parser.StackDto 10 | import com.displayer.display.parser.UnknownDto 11 | import com.displayer.weather.WeatherRepo 12 | import com.russhwolf.settings.Settings 13 | import kotlinx.serialization.json.Json 14 | import kotlinx.serialization.modules.SerializersModule 15 | import kotlinx.serialization.modules.polymorphic 16 | import org.koin.dsl.module 17 | 18 | val coreModule = module { 19 | single { 20 | Json { 21 | prettyPrint = true 22 | isLenient = true 23 | ignoreUnknownKeys = true 24 | serializersModule = SerializersModule { 25 | polymorphic(ContainerDto::class) { 26 | defaultDeserializer { StackDto.serializer() } 27 | } 28 | polymorphic(ItemDto::class) { 29 | defaultDeserializer { UnknownDto.serializer() } 30 | } 31 | } 32 | } 33 | } 34 | single { Settings() } 35 | single { ParametersRepo() } 36 | single { ConfigRepo(get(), get()) } 37 | single { DisplayRepo(get(), get(), get(), get(), get()) } 38 | single { WeatherRepo(get(), get(), get()) } 39 | single { App(get(), get(), get(), get()) } 40 | } 41 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/admin/AdminPanel.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.admin 2 | 3 | import Strings 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Arrangement.spacedBy 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.CompositionLocalProvider 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.text.TextStyle 19 | import androidx.compose.ui.text.font.FontWeight 20 | import com.displayer.BuildKonfig 21 | import com.displayer.app.AppState 22 | import com.displayer.display.DisplayState 23 | import com.displayer.platform.formatTime 24 | import com.displayer.platform.getIcon 25 | import com.displayer.toSp 26 | import com.displayer.ui.DisplayerColor 27 | import com.displayer.ui.Icon 28 | import com.displayer.ui.LocalDimensions 29 | import com.displayer.ui.LocalLocale 30 | import com.displayer.ui.LocalStyle 31 | import com.displayer.ui.Message 32 | import com.displayer.ui.Text 33 | import com.displayer.weather.WeatherState 34 | 35 | @Composable 36 | fun AdminPanel(state: AppState) { 37 | Column( 38 | modifier = Modifier 39 | .fillMaxHeight() 40 | .fillMaxWidth(0.5f) 41 | .background(Color.Black), 42 | ) { 43 | Row( 44 | modifier = Modifier.fillMaxWidth().background(DisplayerColor).padding(LocalDimensions.current.baseUnit), 45 | verticalAlignment = Alignment.CenterVertically, 46 | horizontalArrangement = spacedBy(LocalDimensions.current.baseUnit) 47 | ) { 48 | Image(getIcon(Icon.Logo), null, modifier = Modifier.size(LocalDimensions.current.baseUnit * 2)) 49 | Text("Displayer") 50 | } 51 | Column( 52 | Modifier.padding(LocalDimensions.current.baseUnit), 53 | verticalArrangement = spacedBy(LocalDimensions.current.baseUnit * 0.5f) 54 | ) { 55 | 56 | SectionTitle(Strings.adminSectionApp.toString()) 57 | 58 | Text("${Strings.adminLabelVersion}: ${BuildKonfig.APP_VERSON_NAME} (${BuildKonfig.BUILD_TIME})") 59 | 60 | if (state is AppState.Ready) { 61 | 62 | SectionTitle(Strings.adminSectionAdmin.toString()) 63 | when (state.adminState) { 64 | is AdminState.Running -> Text("${Strings.adminLabelStatus}: ${Strings.adminLabelRunning(state.adminState.port)}") 65 | AdminState.Stopped -> Text("${Strings.adminLabelStatus}: ${Strings.adminLabelStopped}") 66 | } 67 | 68 | SectionTitle(Strings.adminSectionWeather.toString()) 69 | Text("${Strings.adminLabelStatus}: ${getKeyLabel(state.weatherState)}") 70 | 71 | if (state.displayState !is DisplayState.NoDisplay) { 72 | 73 | SectionTitle(Strings.adminSectionDisplay.toString()) 74 | Text("${Strings.adminLabelStatus}: ${getKeyLabel(state.displayState)}") 75 | state.displayState.url?.run { Text("${Strings.adminLabelUrl}: $this") } 76 | Column { 77 | state.displayState.messages.onEach { 78 | Message( 79 | message = it, 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | @Composable 90 | fun getKeyLabel(state: WeatherState): String = when (state) { 91 | is WeatherState.ApiKeyInvalid -> Strings.adminWeatherApiKeyInvalid.toString() 92 | WeatherState.ApiKeyNotSet -> Strings.adminWeatherApiKeyNotSet.toString() 93 | is WeatherState.Failure -> "${Strings.adminStatusFailure} (${formatTime(state.instant, LocalLocale.current)})" 94 | is WeatherState.Success -> "${Strings.adminStatusSuccess} (${formatTime(state.instant, LocalLocale.current)})" 95 | } 96 | 97 | @Composable 98 | fun getKeyLabel(state: DisplayState): String = when (state) { 99 | is DisplayState.NoDisplay -> "" 100 | is DisplayState.Failure -> "${Strings.adminStatusFailure} (${formatTime(state.instant, LocalLocale.current)})" 101 | is DisplayState.Success -> "${Strings.adminStatusSuccess} (${formatTime(state.instant, LocalLocale.current)})" 102 | } 103 | 104 | @Composable 105 | fun SectionTitle(text: String) { 106 | CompositionLocalProvider(LocalStyle provides LocalStyle.current.copy(contentColor = DisplayerColor)) { 107 | Text( 108 | text = text, 109 | style = TextStyle.Default.copy(fontSize = LocalDimensions.current.fontSizeSmall.toSp(), fontWeight = FontWeight.Bold), 110 | ) 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/admin/AdminServer.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.admin 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface AdminServer { 6 | 7 | fun observeState() : Flow 8 | } 9 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/admin/AdminState.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.admin 2 | 3 | sealed class AdminState { 4 | 5 | object Stopped : AdminState() 6 | 7 | data class Running( 8 | val port: Int, 9 | ) : AdminState() 10 | 11 | } -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/app/App.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.app 2 | 3 | import co.touchlab.kermit.Logger 4 | import com.displayer.admin.AdminServer 5 | import com.displayer.config.AdminParameters 6 | import com.displayer.config.ConfigRepo 7 | import com.displayer.display.DisplayRepo 8 | import com.displayer.weather.WeatherRepo 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.combine 13 | import kotlinx.coroutines.launch 14 | 15 | class App( 16 | private val configRepo: ConfigRepo, 17 | private val displayRepo: DisplayRepo, 18 | private val weatherRepo: WeatherRepo, 19 | private val adminServer: AdminServer, 20 | ) { 21 | 22 | private val state = MutableStateFlow(AppState.Initializing) 23 | 24 | init { 25 | GlobalScope.launch { 26 | combine( 27 | displayRepo.observeState(), 28 | weatherRepo.observeState(), 29 | adminServer.observeState(), 30 | ) { displayState, weatherState, adminState -> 31 | AppState.Ready( 32 | displayState = displayState, 33 | weatherState = weatherState, 34 | adminState = adminState, 35 | ) 36 | }.collect { state.value = it } 37 | } 38 | 39 | } 40 | 41 | fun observeState(): Flow = state 42 | 43 | fun setOpenWeatherApiKey(apiKey: String) = configRepo.setOpenWeatherApiKey(apiKey) 44 | 45 | suspend fun loadDisplayFromUrl(url: String?) = displayRepo.loadDisplayFromUrl(url) 46 | 47 | fun setAdminParameters(port: Int?, secret: String?) { 48 | if (port == null) { 49 | Logger.e("Port of admin server is missing") 50 | return 51 | } 52 | if (port <= 0 || port > 65535) { 53 | Logger.e("Port of admin server is invalid: $port") 54 | return 55 | } 56 | if (secret.isNullOrBlank()) { 57 | Logger.e("Secret of admin server is missing") 58 | return 59 | } 60 | configRepo.setAdminParameters(AdminParameters(port, secret)) 61 | } 62 | 63 | fun stopAdminServer() { 64 | configRepo.setAdminParameters(null) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/app/AppState.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.app 2 | 3 | import com.displayer.admin.AdminState 4 | import com.displayer.display.DisplayState 5 | import com.displayer.weather.WeatherState 6 | 7 | sealed class AppState { 8 | 9 | object Initializing : AppState() 10 | 11 | data class Ready( 12 | val weatherState: WeatherState, 13 | val displayState: DisplayState, 14 | val adminState: AdminState, 15 | ) : AppState() 16 | 17 | } 18 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/app/AppUi.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) 2 | 3 | package com.displayer.app 4 | 5 | import androidx.compose.animation.AnimatedVisibility 6 | import androidx.compose.animation.Crossfade 7 | import androidx.compose.animation.fadeIn 8 | import androidx.compose.animation.fadeOut 9 | import androidx.compose.animation.slideInHorizontally 10 | import androidx.compose.animation.slideOutHorizontally 11 | import androidx.compose.foundation.ExperimentalFoundationApi 12 | import androidx.compose.foundation.background 13 | import androidx.compose.foundation.focusable 14 | import androidx.compose.foundation.gestures.detectTapGestures 15 | import androidx.compose.foundation.layout.Box 16 | import androidx.compose.foundation.layout.BoxWithConstraints 17 | import androidx.compose.foundation.layout.fillMaxSize 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.ExperimentalComposeUiApi 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.focus.FocusRequester 29 | import androidx.compose.ui.focus.focusRequester 30 | import androidx.compose.ui.focus.onFocusChanged 31 | import androidx.compose.ui.graphics.Color 32 | import androidx.compose.ui.input.key.Key 33 | import androidx.compose.ui.input.key.KeyEventType 34 | import androidx.compose.ui.input.key.key 35 | import androidx.compose.ui.input.key.onKeyEvent 36 | import androidx.compose.ui.input.key.type 37 | import androidx.compose.ui.input.pointer.pointerInput 38 | import com.displayer.admin.AdminPanel 39 | import com.displayer.display.DisplayScreen 40 | import com.displayer.ui.Dimensions 41 | import com.displayer.ui.LocalDimensions 42 | import com.displayer.ui.LocalStyle 43 | 44 | @Composable 45 | fun AppUi(state: AppState) { 46 | var showAdminPanel by remember { mutableStateOf(false) } 47 | val focusRequester = remember { FocusRequester() } 48 | var hasFocus by remember { mutableStateOf(false) } 49 | 50 | BoxWithConstraints( 51 | modifier = Modifier 52 | .fillMaxSize() 53 | .focusRequester(focusRequester) 54 | .onFocusChanged { 55 | hasFocus = it.hasFocus 56 | } 57 | .focusable() 58 | .background(LocalStyle.current.backgroundColor) 59 | .pointerInput(Unit) { 60 | detectTapGestures(onDoubleTap = { showAdminPanel = !showAdminPanel }) 61 | } 62 | .onKeyEvent { 63 | if (it.type == KeyEventType.KeyUp) { 64 | if (it.key == Key.DirectionCenter) { 65 | showAdminPanel = !showAdminPanel 66 | return@onKeyEvent true 67 | } 68 | } 69 | return@onKeyEvent false 70 | }, 71 | contentAlignment = Alignment.CenterEnd 72 | ) { 73 | CompositionLocalProvider(LocalDimensions provides Dimensions(maxWidth, maxHeight)) { 74 | Crossfade(targetState = state) { 75 | when (it) { 76 | AppState.Initializing -> InitializingScreen() 77 | is AppState.Ready -> DisplayScreen(it.displayState) 78 | } 79 | } 80 | AnimatedVisibility(showAdminPanel, enter = fadeIn(), exit = fadeOut()) { 81 | Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.5f))) 82 | } 83 | AnimatedVisibility(showAdminPanel, enter = slideInHorizontally(initialOffsetX = { (it * 1.5f).toInt() }), exit = slideOutHorizontally(targetOffsetX = { (it * 1.5f).toInt() })) { 84 | AdminPanel(state) 85 | } 86 | } 87 | } 88 | LaunchedEffect(Unit) { 89 | focusRequester.requestFocus() 90 | } 91 | } 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/app/InitializingScreen.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.app 2 | 3 | import Strings 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.TextStyle 17 | import com.displayer.toSp 18 | import com.displayer.ui.LocalDimensions 19 | import com.displayer.ui.Text 20 | import kotlinx.coroutines.delay 21 | 22 | @Composable 23 | fun InitializingScreen() { 24 | var showLoading by remember { mutableStateOf(false) } 25 | AnimatedVisibility(showLoading, enter = fadeIn()) { 26 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 27 | Text(Strings.loading.toString(), style = TextStyle.Default.copy(fontSize = LocalDimensions.current.fontSize.toSp())) 28 | } 29 | } 30 | LaunchedEffect(true) { 31 | delay(1000) 32 | showLoading = true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/config/Config.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.config 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Config( 7 | val displayUrl: String? = null, 8 | val openWeatherApiKey: String? = null, 9 | val adminParameters: AdminParameters? = null, 10 | ) 11 | 12 | @Serializable 13 | data class AdminParameters( 14 | val port: Int, 15 | val secret: String, 16 | ) 17 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/config/ConfigRepo.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.config 2 | 3 | import co.touchlab.kermit.Logger 4 | import com.russhwolf.settings.Settings 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.serialization.decodeFromString 8 | import kotlinx.serialization.encodeToString 9 | import kotlinx.serialization.json.Json 10 | 11 | /* 12 | Manages/persists the configuration of the app 13 | */ 14 | class ConfigRepo( 15 | private val json: Json, 16 | private val settings: Settings, 17 | ) { 18 | 19 | private var configFlow = MutableStateFlow(json.decodeFromString(settings.getStringOrNull(KEY_CONFIG) ?: "{}")) 20 | 21 | fun observeConfig(): Flow = configFlow 22 | 23 | private fun updateConfig(config: Config) { 24 | configFlow.value = config 25 | settings.putString(KEY_CONFIG, json.encodeToString(config)) 26 | } 27 | 28 | fun setOpenWeatherApiKey(apiKey: String) { 29 | Logger.d("Setting OpenWeather API key to $apiKey") 30 | updateConfig(configFlow.value.copy(openWeatherApiKey = apiKey)) 31 | } 32 | 33 | fun getDisplayUrl(): String? = configFlow.value.displayUrl 34 | 35 | fun setDisplayUrl(url: String) { 36 | updateConfig(configFlow.value.copy(displayUrl = url)) 37 | } 38 | 39 | fun setAdminParameters(params: AdminParameters?) { 40 | if (params == null) { 41 | Logger.d("Clearing admin parameters") 42 | } else { 43 | Logger.d("Setting admin parameters: port=${params.port} secret=XXXX") 44 | } 45 | updateConfig(configFlow.value.copy(adminParameters = params)) 46 | } 47 | 48 | companion object { 49 | const val KEY_CONFIG = "Displayer.Config" 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/config/Parameters.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.config 2 | 3 | import com.displayer.display.parser.Units 4 | 5 | data class Parameters( 6 | val language: String, 7 | val country: String, 8 | val zip: String? = null, 9 | val units: Units = Units.Metric, 10 | ) 11 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/config/ParametersRepo.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.config 2 | 3 | import co.touchlab.kermit.Logger 4 | import com.displayer.display.parser.Parser 5 | import de.comahe.i18n4k.config.I18n4kConfigDefault 6 | import de.comahe.i18n4k.i18n4k 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | 10 | /* 11 | Manages the parameters specified in a display file 12 | */ 13 | class ParametersRepo { 14 | 15 | private var parametersFlow = MutableStateFlow(Parser.parseParameters(null)) 16 | 17 | fun observeParameters(): Flow = parametersFlow 18 | 19 | fun setParameters(parameters: Parameters) { 20 | parametersFlow.value = parameters 21 | activateLanguage(parameters.language) 22 | } 23 | 24 | private fun activateLanguage(language: String) { 25 | Logger.i("Setting language to $language") 26 | val i18n4kConfig = I18n4kConfigDefault() 27 | i18n4k = i18n4kConfig 28 | i18n4kConfig.locale = de.comahe.i18n4k.Locale(language) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/DefaultDisplayFile.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import com.displayer.display.parser.ClockDto 4 | import com.displayer.display.parser.ColumnDto 5 | import com.displayer.display.parser.DisplayFile 6 | import com.displayer.display.parser.ImageDto 7 | import com.displayer.display.parser.ParametersDto 8 | import com.displayer.display.parser.RowDto 9 | import com.displayer.display.parser.ScaleDto 10 | import com.displayer.display.parser.StackDto 11 | import com.displayer.display.parser.StyleDto 12 | import com.displayer.display.parser.TextDto 13 | 14 | val defaultDisplayFile = DisplayFile( 15 | parameters = ParametersDto( 16 | language = "en", 17 | country = "BE", 18 | ), 19 | styleId = "light", 20 | center = StackDto( 21 | items = listOf( 22 | TextDto("Displayer Demo"), 23 | TextDto("Lorem ipsum dolor sit amet, consectetur adipiscing elit"), 24 | ImageDto(url = "https://picsum.photos/id/24/960/540", scale = ScaleDto.Crop), 25 | TextDto("Maecenas consectetur in erat sit amet condimentum."), 26 | ImageDto(url = "https://picsum.photos/id/56/960/540", scale = ScaleDto.Crop), 27 | TextDto("Etiam nec ipsum et massa accumsan pulvinar."), 28 | ), 29 | autoAdvanceInSeconds = 5, 30 | ), 31 | left = ColumnDto( 32 | styleId = "darker", 33 | items = listOf( 34 | ClockDto(), 35 | TextDto("⚝"), 36 | ) 37 | ), 38 | bottom = RowDto( 39 | styleId = "dark", 40 | items = listOf( 41 | TextDto("Maecenas consectetur in erat sit amet condimentum."), 42 | ImageDto(url = "https://picsum.photos/id/106/640/480"), 43 | TextDto("Lorem ipsum dolor sit amet, consectetur adipiscing elit"), 44 | ImageDto(url = "https://picsum.photos/id/159/960/540"), 45 | TextDto("Etiam nec ipsum et massa accumsan pulvinar."), 46 | ), 47 | divider = TextDto("•"), 48 | ), 49 | styles = listOf( 50 | StyleDto(id = "light", backgroundColor = "#ffffff", contentColor = "#1E9991"), 51 | StyleDto(id = "dark", backgroundColor = "#1E9991", contentColor = "#ffffff"), 52 | StyleDto(id = "darker", backgroundColor = "#17756F", contentColor = "#ffffff"), 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/Display.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.intl.Locale 5 | import com.displayer.display.container.Container 6 | 7 | data class Display( 8 | val locale : Locale, 9 | val refreshInMinutes: Int = 0, 10 | val style: Style, 11 | val center: Container, 12 | val left: Container, 13 | val bottom: Container, 14 | val bottomHeight: Float, 15 | ) { 16 | companion object { 17 | const val DEFAULT_BOTTOM_HEIGHT: Float = 6f 18 | } 19 | 20 | } 21 | 22 | data class Style( 23 | val id: String, 24 | val backgroundColor: Color, 25 | val contentColor: Color, 26 | ) 27 | 28 | val defaultStyle = Style( 29 | id = "displayer.style.dark", 30 | backgroundColor = Color.Black, 31 | contentColor = Color.White, 32 | ) 33 | 34 | data class Padding( 35 | val horizontal: Float = 0f, 36 | val vertical: Float = 0f, 37 | ) 38 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/DisplayState.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import com.displayer.display.parser.Message 4 | import kotlinx.collections.immutable.ImmutableList 5 | import kotlinx.collections.immutable.persistentListOf 6 | import kotlinx.datetime.Clock 7 | import kotlinx.datetime.Instant 8 | 9 | sealed class DisplayState( 10 | open val instant: Instant, 11 | open val url: String? = null, 12 | open val messages: ImmutableList = persistentListOf() 13 | ) { 14 | class NoDisplay : DisplayState(Clock.System.now()) 15 | 16 | data class Success( 17 | override val url: String? = null, 18 | override val messages: ImmutableList, 19 | val display: Display, 20 | ) : DisplayState(Clock.System.now(), url, messages) 21 | 22 | data class Failure( 23 | override val url: String?, 24 | override val messages: ImmutableList, 25 | ) : DisplayState(Clock.System.now(), url, messages) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/DisplayUi.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.runtime.Composable 5 | 6 | @Composable 7 | fun DisplayScreen(state: DisplayState) { 8 | Crossfade(targetState = state) { 9 | when (it) { 10 | is DisplayState.Success -> MainUi(it.display) 11 | is DisplayState.Failure -> ErrorUi(it) 12 | is DisplayState.NoDisplay -> {} 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/ErrorUi.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import Strings 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement.spacedBy 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.rememberScrollState 14 | import androidx.compose.foundation.text.BasicText 15 | import androidx.compose.foundation.verticalScroll 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.layout.ContentScale 21 | import androidx.compose.ui.text.TextStyle 22 | import com.displayer.toSp 23 | import com.displayer.ui.LocalDimensions 24 | import com.displayer.ui.Message 25 | import io.kamel.image.KamelImage 26 | import io.kamel.image.lazyPainterResource 27 | import io.ktor.http.* 28 | 29 | @Composable 30 | fun ErrorUi(displayFailed: DisplayState.Failure) { 31 | Column( 32 | modifier = Modifier.fillMaxSize(), 33 | ) { 34 | BasicText( 35 | text = Strings.errorTitle.toString(), 36 | modifier = Modifier.fillMaxWidth().background(Color.Red).padding(horizontal = LocalDimensions.current.baseUnit * 2, vertical = LocalDimensions.current.baseUnit), 37 | style = TextStyle.Default.copy(color = Color.White, fontSize = LocalDimensions.current.fontSize.toSp()) 38 | ) 39 | Row( 40 | modifier = Modifier.fillMaxWidth(), 41 | ) { 42 | Column( 43 | modifier = Modifier.weight(1f, true).fillMaxHeight().padding(LocalDimensions.current.baseUnit * 2).verticalScroll(rememberScrollState()), 44 | verticalArrangement = spacedBy(LocalDimensions.current.baseUnit * 2) 45 | ) { 46 | val textStyle = TextStyle.Default.copy(fontSize = LocalDimensions.current.fontSize.toSp()) 47 | displayFailed.messages.onEach { 48 | Message(it) 49 | } 50 | } 51 | displayFailed.url?.run { 52 | Box( 53 | modifier = Modifier.fillMaxHeight().fillMaxWidth(0.2f).padding(LocalDimensions.current.baseUnit), 54 | contentAlignment = Alignment.BottomCenter, 55 | ) { 56 | Column( 57 | horizontalAlignment = Alignment.CenterHorizontally, 58 | ) { 59 | BasicText(text = "Config URL") 60 | KamelImage( 61 | lazyPainterResource(data = "https://quickchart.io/qr?size=300&text=${encodeURLParameter()}"), 62 | contentDescription = null, 63 | modifier = Modifier.fillMaxWidth(), 64 | contentScale = ContentScale.FillWidth, 65 | ) 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/MainUi.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.aspectRatio 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.ui.Modifier 14 | import com.displayer.display.container.ContainerUi 15 | import com.displayer.ui.LocalDimensions 16 | import com.displayer.ui.LocalLocale 17 | import com.displayer.ui.LocalStyle 18 | import com.displayer.ui.StyledContent 19 | 20 | @Composable 21 | fun MainUi(display: Display) { 22 | with(display) { 23 | CompositionLocalProvider(LocalLocale provides (display.locale)) { 24 | StyledContent(style) { 25 | Column( 26 | modifier = Modifier.fillMaxSize().background(LocalStyle.current.backgroundColor) 27 | ) { 28 | val bottomHeight = LocalDimensions.current.baseUnit * display.bottomHeight 29 | Row(Modifier.height(LocalDimensions.current.screenHeight - bottomHeight)) { 30 | if (bottomHeight.value > 0f) { 31 | ContainerUi(left, Modifier.weight(1f, true).fillMaxSize()) 32 | } 33 | ContainerUi(center, Modifier.fillMaxHeight().aspectRatio(LocalDimensions.current.screenAspectRatio)) 34 | } 35 | if (bottomHeight.value > 0f) { 36 | ContainerUi(bottom, Modifier.height(bottomHeight).fillMaxWidth()) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/container/ColumnLayout.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.container 2 | 3 | import androidx.compose.animation.core.LinearEasing 4 | import androidx.compose.animation.core.RepeatMode 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.LaunchedEffect 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import com.displayer.display.Padding 19 | import com.displayer.display.Style 20 | import com.displayer.display.item.Item 21 | import com.displayer.display.item.ItemUi 22 | import com.displayer.toPx 23 | import com.displayer.ui.Direction 24 | import com.displayer.ui.LocalDimensions 25 | import kotlinx.collections.immutable.ImmutableList 26 | import kotlinx.coroutines.delay 27 | 28 | data class ColumnLayout( 29 | override val padding: Padding, 30 | override val style: Style? = null, 31 | override val items: ImmutableList, 32 | val spacing: Float? = null, 33 | val scrollSpeedSeconds: Float, 34 | ) : Container( 35 | direction = Direction.Vertical, 36 | ) { 37 | 38 | companion object { 39 | val DEFAULT_PADDING_SCROLLING: Padding = Padding(1f, 0f) 40 | val DEFAULT_PADDING_STATIC: Padding = Padding(1f, 1f) 41 | const val DEFAULT_SCROLL_SPEED: Float = 0f 42 | } 43 | } 44 | 45 | @Composable 46 | fun ColumnLayoutUi(container: ColumnLayout) { 47 | with(container) { 48 | if (scrollSpeedSeconds > 0f) { 49 | val scrollState = rememberScrollState() 50 | val scrollDistance = LocalDimensions.current.screenHeight 51 | Column( 52 | modifier = Modifier.fillMaxSize().verticalScroll(scrollState), 53 | horizontalAlignment = Alignment.CenterHorizontally, 54 | verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit * (container.spacing ?: container.padding.horizontal)), 55 | ) { 56 | Spacer(Modifier.height(scrollDistance)) 57 | items.forEach { ItemUi(it) } 58 | Spacer(Modifier.height(scrollDistance)) 59 | } 60 | 61 | val durationFactor: Int = (scrollState.maxValue / scrollDistance.toPx()).toInt() 62 | LaunchedEffect(scrollState.maxValue) { 63 | delay(1000) 64 | scrollState.scrollTo(0) 65 | scrollState.animateScrollTo( 66 | scrollState.maxValue, infiniteRepeatable( 67 | animation = tween((scrollSpeedSeconds * 1000 * durationFactor).toInt(), easing = LinearEasing), repeatMode = RepeatMode.Restart 68 | ) 69 | ) 70 | } 71 | } else { 72 | Column( 73 | modifier = Modifier.fillMaxSize(), 74 | horizontalAlignment = Alignment.CenterHorizontally, 75 | verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit * (container.spacing ?: container.padding.horizontal)), 76 | ) { 77 | items.forEach { ItemUi(it) } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/container/Container.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.container 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.style.TextAlign 11 | import com.displayer.display.Padding 12 | import com.displayer.display.Style 13 | import com.displayer.display.item.Item 14 | import com.displayer.ui.Direction 15 | import com.displayer.ui.LocalDimensions 16 | import com.displayer.ui.LocalDirection 17 | import com.displayer.ui.LocalStyle 18 | import com.displayer.ui.LocalTextAlign 19 | import com.displayer.ui.StyledContent 20 | import kotlinx.collections.immutable.ImmutableList 21 | 22 | sealed class Container( 23 | val direction: Direction = Direction.None, 24 | val textAlign: TextAlign = TextAlign.Center, 25 | ) { 26 | abstract val padding: Padding 27 | abstract val style: Style? 28 | abstract val items: ImmutableList 29 | } 30 | 31 | @Composable 32 | fun ContainerUi(container: Container, modifier: Modifier) { 33 | StyledContent(container.style) { 34 | CompositionLocalProvider( 35 | LocalDirection provides container.direction, 36 | LocalTextAlign provides container.textAlign, 37 | ) { 38 | val paddingHorizontal = LocalDimensions.current.baseUnit * container.padding.horizontal 39 | val paddingVertical = LocalDimensions.current.baseUnit * container.padding.vertical 40 | Box(modifier = modifier.background(LocalStyle.current.backgroundColor).fillMaxSize().padding(horizontal = paddingHorizontal, vertical = paddingVertical)) { 41 | when (container) { 42 | is Empty -> EmptyUi() 43 | is StackLayout -> StackLayoutUi(container) 44 | is ColumnLayout -> ColumnLayoutUi(container) 45 | is RowLayout -> RowLayoutUi(container) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/container/Empty.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.container 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.displayer.display.Padding 5 | import com.displayer.display.Style 6 | import com.displayer.display.item.Item 7 | import kotlinx.collections.immutable.ImmutableList 8 | import kotlinx.collections.immutable.persistentListOf 9 | 10 | data class Empty( 11 | override val padding: Padding = Padding(0f, 0f), 12 | override val style: Style? = null, 13 | override val items: ImmutableList = persistentListOf(), 14 | ) : Container() 15 | 16 | @Composable 17 | fun EmptyUi() { 18 | } 19 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/container/RowLayout.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.container 2 | 3 | import androidx.compose.animation.core.LinearEasing 4 | import androidx.compose.animation.core.RepeatMode 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.horizontalScroll 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.rememberScrollState 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.LaunchedEffect 16 | import androidx.compose.ui.Alignment.Companion.CenterVertically 17 | import androidx.compose.ui.Modifier 18 | import com.displayer.display.Padding 19 | import com.displayer.display.Style 20 | import com.displayer.display.item.Item 21 | import com.displayer.display.item.ItemUi 22 | import com.displayer.toPx 23 | import com.displayer.ui.Direction 24 | import com.displayer.ui.LocalDimensions 25 | import kotlinx.collections.immutable.ImmutableList 26 | import kotlinx.coroutines.delay 27 | 28 | data class RowLayout( 29 | override val padding: Padding, 30 | override val style: Style? = null, 31 | override val items: ImmutableList, 32 | val spacing: Float? = null, 33 | val scrollSpeedSeconds: Float, 34 | ) : Container( 35 | direction = Direction.Horizontal, 36 | ) { 37 | 38 | companion object { 39 | val DEFAULT_PADDING_SCROLLING: Padding = Padding(0f, 1f) 40 | val DEFAULT_PADDING_STATIC: Padding = Padding(1f, 1f) 41 | const val DEFAULT_SCROLL_SPEED: Float = 12f 42 | } 43 | } 44 | 45 | @Composable 46 | fun RowLayoutUi(container: RowLayout) { 47 | with(container) { 48 | if (scrollSpeedSeconds > 0f) { 49 | val scrollState = rememberScrollState() 50 | val scrollDistance = LocalDimensions.current.screenWidth 51 | Row( 52 | modifier = Modifier.fillMaxSize().horizontalScroll(state = scrollState, enabled = false), 53 | horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit * (container.spacing ?: container.padding.vertical)), 54 | ) { 55 | Spacer(Modifier.width(scrollDistance)) 56 | items.forEach { ItemUi(it) } 57 | Spacer(Modifier.width(scrollDistance)) 58 | } 59 | 60 | val durationFactor: Int = (scrollState.maxValue / scrollDistance.toPx()).toInt() 61 | LaunchedEffect(scrollState.maxValue) { 62 | delay(1000) 63 | scrollState.scrollTo(0) 64 | scrollState.animateScrollTo( 65 | scrollState.maxValue, infiniteRepeatable( 66 | animation = tween((scrollSpeedSeconds * 1000 * durationFactor).toInt(), easing = LinearEasing), repeatMode = RepeatMode.Restart 67 | ) 68 | ) 69 | } 70 | } else { 71 | Row( 72 | modifier = Modifier.fillMaxSize(), 73 | horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit * (container.spacing ?: container.padding.vertical)), 74 | verticalAlignment = CenterVertically, 75 | ) { 76 | items.forEach { ItemUi(it) } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/container/Stacked.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.container 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.animation.fadeIn 7 | import androidx.compose.animation.fadeOut 8 | import androidx.compose.animation.with 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import com.displayer.display.Padding 18 | import com.displayer.display.Style 19 | import com.displayer.display.item.Item 20 | import com.displayer.display.item.ItemUi 21 | import kotlinx.collections.immutable.ImmutableList 22 | import kotlinx.coroutines.delay 23 | 24 | data class StackLayout( 25 | override val padding: Padding, 26 | override val style: Style? = null, 27 | override val items: ImmutableList, 28 | val autoAdvanceInSeconds: Int, 29 | ) : Container() { 30 | 31 | companion object { 32 | const val DEFAULT_AUTO_ADVANCE_IN_SECONDS = 30 33 | val DEFAULT_PADDING: Padding = Padding(0f, 0f) 34 | } 35 | } 36 | 37 | @OptIn(ExperimentalAnimationApi::class) 38 | @Composable 39 | fun StackLayoutUi(container: StackLayout) { 40 | with(container) { 41 | val currentItem = remember { mutableStateOf(0) } 42 | if (items.isNotEmpty()) { 43 | LaunchedEffect(true) { 44 | while (true) { 45 | delay(autoAdvanceInSeconds * 1000L) 46 | currentItem.value = (currentItem.value + 1) % items.size 47 | } 48 | } 49 | } 50 | val animationDuration = 500 51 | AnimatedContent( 52 | targetState = currentItem.value, 53 | transitionSpec = { fadeIn(tween(animationDuration)) with fadeOut(tween(animationDuration)) }) { targetState -> 54 | Box( 55 | modifier = Modifier.fillMaxSize(), 56 | contentAlignment = Alignment.Center 57 | ) { 58 | items.getOrNull(targetState.coerceAtMost((items.size - 1).coerceAtLeast(0)))?.run { 59 | ItemUi(this) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Clock.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import com.displayer.display.Padding 10 | import com.displayer.display.Style 11 | import com.displayer.platform.formatTime 12 | import com.displayer.ui.LocalLocale 13 | import kotlinx.coroutines.delay 14 | 15 | data class Clock( 16 | override val style: Style? = null, 17 | override val padding: Padding = Padding(), 18 | ) : Item() 19 | 20 | @Composable 21 | fun ClockUi() { 22 | var timeString by remember { mutableStateOf("") } 23 | val locale = LocalLocale.current 24 | LaunchedEffect(true) { 25 | while (true) { 26 | timeString = formatTime(kotlinx.datetime.Clock.System.now(), locale) 27 | delay(200) 28 | } 29 | } 30 | AutoFitText(text = timeString) 31 | } 32 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Drink.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.layout.ContentScale 11 | import androidx.compose.ui.text.TextStyle 12 | import com.displayer.display.Padding 13 | import com.displayer.display.Style 14 | import com.displayer.ui.LocalDimensions 15 | import com.displayer.ui.LocalStyle 16 | import com.displayer.ui.LocalTextAlign 17 | import io.kamel.image.KamelImage 18 | import io.kamel.image.lazyPainterResource 19 | 20 | data class DrinkItem( 21 | val image: String, 22 | val text: String, 23 | override val style: Style? = null, 24 | override val padding: Padding = Padding(), 25 | val spacing: Float? = DEFAULT_SPACING, 26 | ) : Item() { 27 | 28 | companion object { 29 | const val DEFAULT_SPACING = 1f 30 | } 31 | } 32 | 33 | @Composable 34 | fun DrinkItemUi(item: DrinkItem) { 35 | Column( 36 | modifier = Modifier.fillMaxSize(), 37 | horizontalAlignment = Alignment.CenterHorizontally, 38 | verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit * (item.spacing ?: item.padding.horizontal)), 39 | ) { 40 | Box( 41 | modifier = Modifier.weight(1f), 42 | ) { 43 | KamelImage( 44 | lazyPainterResource(data = item.image), 45 | contentDescription = null, 46 | modifier = Modifier.fillMaxSize(), 47 | contentScale = ContentScale.Fit 48 | ) 49 | } 50 | val actualStyle = TextStyle.Default.copy( 51 | color = LocalStyle.current.contentColor, 52 | textAlign = LocalTextAlign.current, 53 | ) 54 | UnscaledText(item.text, actualStyle) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Image.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.animation.core.FiniteAnimationSpec 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.BoxScope 6 | import androidx.compose.foundation.layout.fillMaxHeight 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.ColorFilter 13 | import androidx.compose.ui.graphics.DefaultAlpha 14 | import androidx.compose.ui.graphics.painter.Painter 15 | import androidx.compose.ui.layout.ContentScale 16 | import com.displayer.display.Padding 17 | import com.displayer.display.Style 18 | import com.displayer.ui.Direction 19 | import com.displayer.ui.LocalDirection 20 | import io.kamel.core.ExperimentalKamelApi 21 | import io.kamel.core.Resource 22 | import io.kamel.image.KamelImageBox 23 | import io.kamel.image.lazyPainterResource 24 | 25 | data class Image( 26 | val url: String, 27 | override val style: Style? = null, 28 | override val padding: Padding = Padding(), 29 | val scale: ContentScale, 30 | ) : Item() 31 | 32 | 33 | @Composable 34 | fun ImageUi(item: Image) { 35 | BetterKamelImage( 36 | lazyPainterResource(data = item.url), 37 | contentDescription = null, 38 | modifier = when (LocalDirection.current) { 39 | Direction.None -> Modifier.fillMaxSize() 40 | Direction.Horizontal -> Modifier.fillMaxHeight() 41 | Direction.Vertical -> Modifier.fillMaxWidth() 42 | }, 43 | contentScale = when (LocalDirection.current) { 44 | Direction.None -> item.scale 45 | Direction.Horizontal -> ContentScale.FillHeight 46 | Direction.Vertical -> ContentScale.FillWidth 47 | } 48 | ) 49 | } 50 | 51 | @OptIn(ExperimentalKamelApi::class) 52 | @Composable 53 | public fun BetterKamelImage( 54 | resource: Resource, 55 | contentDescription: String?, 56 | modifier: Modifier = Modifier, 57 | alignment: Alignment = Alignment.Center, 58 | contentScale: ContentScale = ContentScale.Fit, 59 | alpha: Float = DefaultAlpha, 60 | colorFilter: ColorFilter? = null, 61 | onLoading: @Composable (BoxScope.(Float) -> Unit)? = null, 62 | onFailure: @Composable (BoxScope.(Throwable) -> Unit)? = null, 63 | contentAlignment: Alignment = Alignment.Center, 64 | animationSpec: FiniteAnimationSpec? = null, 65 | ) { 66 | val onSuccess: @Composable (BoxScope.(Painter) -> Unit) = { painter -> 67 | Image( 68 | painter, 69 | contentDescription, 70 | modifier, 71 | alignment, 72 | contentScale, 73 | alpha, 74 | colorFilter 75 | ) 76 | } 77 | KamelImageBox( 78 | resource, 79 | modifier, 80 | contentAlignment, 81 | animationSpec, 82 | onLoading, 83 | onFailure, 84 | onSuccess, 85 | ) 86 | } -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Item.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import com.displayer.display.Padding 8 | import com.displayer.display.Style 9 | import com.displayer.ui.LocalDimensions 10 | import com.displayer.ui.StyledContent 11 | 12 | sealed class Item { 13 | abstract val style: Style? 14 | abstract val padding: Padding 15 | } 16 | 17 | @Composable 18 | fun ItemUi(item: Item) { 19 | StyledContent(item.style) { 20 | val paddingHorizontal = LocalDimensions.current.baseUnit * item.padding.horizontal 21 | val paddingVertical = LocalDimensions.current.baseUnit * item.padding.vertical 22 | Box(modifier = Modifier.padding(horizontal = paddingHorizontal, vertical = paddingVertical)) { 23 | when (item) { 24 | is UnknownItem -> UnknownItemUi() 25 | is Clock -> ClockUi() 26 | is Image -> ImageUi(item) 27 | is TextItem -> TextItemUi(item) 28 | is RandomItem -> RandomItemUi(item) 29 | is Weather -> WeatherUi(item) 30 | is DrinkItem -> DrinkItemUi(item) 31 | is SocialItem -> SocialItemUi(item) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/RandomItem.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.runtime.Composable 4 | import co.touchlab.kermit.Logger 5 | import com.displayer.display.Padding 6 | import com.displayer.display.Style 7 | import kotlinx.collections.immutable.ImmutableList 8 | import kotlinx.collections.immutable.persistentListOf 9 | import kotlin.random.Random 10 | 11 | data class RandomItem( 12 | val items: ImmutableList = persistentListOf(), 13 | override val style: Style? = null, 14 | override val padding: Padding = Padding(), 15 | ) : Item() 16 | 17 | @Composable 18 | fun RandomItemUi(item: RandomItem) { 19 | if (item.items.isEmpty()) { 20 | return 21 | } 22 | val index = Random.nextInt(0, item.items.size) 23 | Logger.i { "Picked random item $index" } 24 | ItemUi(item.items[index]) 25 | } 26 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Social.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.aspectRatio 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.layout.ContentScale 17 | import androidx.compose.ui.text.TextStyle 18 | import com.displayer.display.Padding 19 | import com.displayer.display.Style 20 | import com.displayer.display.parser.SocialApp 21 | import com.displayer.isLight 22 | import com.displayer.platform.getIcon 23 | import com.displayer.ui.Direction 24 | import com.displayer.ui.Icon 25 | import com.displayer.ui.LocalDimensions 26 | import com.displayer.ui.LocalDirection 27 | import com.displayer.ui.LocalStyle 28 | import com.displayer.ui.LocalTextAlign 29 | 30 | data class SocialItem( 31 | val app: SocialApp, 32 | val account: String, 33 | val text: String?, 34 | override val style: Style? = null, 35 | override val padding: Padding = Padding(), 36 | ) : Item() 37 | 38 | @Composable 39 | fun SocialItemUi(item: SocialItem) { 40 | when (LocalDirection.current) { 41 | Direction.None -> SocialItemOther(item) 42 | Direction.Horizontal -> SocialItemRow(item) 43 | Direction.Vertical -> SocialItemColumn(item) 44 | } 45 | } 46 | 47 | @Composable 48 | fun SocialItemOther(item: SocialItem) { 49 | Column( 50 | modifier = Modifier.fillMaxSize(), 51 | horizontalAlignment = Alignment.CenterHorizontally, 52 | verticalArrangement = Arrangement.SpaceEvenly, 53 | ) { 54 | val actualStyle = TextStyle.Default.copy( 55 | color = LocalStyle.current.contentColor, 56 | textAlign = LocalTextAlign.current, 57 | ) 58 | Spacer(modifier = Modifier.weight(2f)) 59 | item.text?.run { 60 | UnscaledText(this, actualStyle) 61 | } 62 | Spacer(modifier = Modifier.weight(1f)) 63 | Box( 64 | modifier = Modifier.weight(6f).aspectRatio(2f), 65 | ) { 66 | Image( 67 | getIcon(item.app.asIcon()), 68 | contentDescription = null, 69 | modifier = Modifier.fillMaxSize(), 70 | contentScale = ContentScale.Fit 71 | ) 72 | } 73 | Spacer(modifier = Modifier.weight(1f)) 74 | UnscaledText(item.account, actualStyle) 75 | Spacer(modifier = Modifier.weight(2f)) 76 | } 77 | } 78 | 79 | @Composable 80 | fun SocialItemRow(item: SocialItem) { 81 | Row( 82 | horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit), 83 | verticalAlignment = Alignment.CenterVertically, 84 | ) { 85 | Image( 86 | getIcon(item.app.asIcon()), 87 | contentDescription = null, 88 | modifier = Modifier.fillMaxHeight(), 89 | contentScale = ContentScale.FillHeight 90 | ) 91 | AutoFitText(listOfNotNull(item.text, item.account).joinToString(": ")) 92 | } 93 | } 94 | 95 | @Composable 96 | fun SocialItemColumn(item: SocialItem) { 97 | Column( 98 | modifier = Modifier.fillMaxWidth(), 99 | horizontalAlignment = Alignment.CenterHorizontally, 100 | verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.baseUnit), 101 | ) { 102 | Image( 103 | getIcon(item.app.asIcon()), 104 | contentDescription = null, 105 | modifier = Modifier.fillMaxWidth(0.8f), 106 | contentScale = ContentScale.FillWidth 107 | ) 108 | AutoFitText(item.account) 109 | } 110 | } 111 | 112 | @Composable 113 | fun SocialApp.asIcon() = when (this) { 114 | SocialApp.Facebook -> Icon.Facebook 115 | SocialApp.Instagram -> Icon.Instagram 116 | SocialApp.Tiktok -> Icon.TikTok 117 | SocialApp.YouTube -> if (LocalStyle.current.backgroundColor.isLight()) Icon.YouTubeLight else Icon.YouTubeDark 118 | SocialApp.Snapchat -> Icon.Snapchat 119 | } 120 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/TextItem.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.BoxWithConstraints 5 | import androidx.compose.foundation.layout.fillMaxHeight 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.text.BasicText 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalDensity 13 | import androidx.compose.ui.platform.LocalFontFamilyResolver 14 | import androidx.compose.ui.text.ParagraphIntrinsics 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.unit.sp 17 | import com.displayer.display.Padding 18 | import com.displayer.display.Style 19 | import com.displayer.toSp 20 | import com.displayer.ui.Direction 21 | import com.displayer.ui.LocalDirection 22 | import com.displayer.ui.LocalStyle 23 | import com.displayer.ui.LocalTextAlign 24 | 25 | data class TextItem( 26 | val text: String, 27 | override val style: Style? = null, 28 | override val padding: Padding = Padding(), 29 | ) : Item() 30 | 31 | @Composable 32 | fun TextItemUi(item: TextItem) { 33 | AutoFitText(text = item.text) 34 | } 35 | 36 | @Composable 37 | fun AutoFitText( 38 | text: String, 39 | modifier: Modifier = Modifier, 40 | style: TextStyle = TextStyle.Default, 41 | ) { 42 | val actualStyle = style.copy( 43 | color = LocalStyle.current.contentColor, 44 | textAlign = LocalTextAlign.current, 45 | // FIXME: Disable includeFontPadding. See https://medium.com/androiddevelopers/fixing-font-padding-in-compose-text-768cd232425b 46 | // platformStyle = PlatformTextStyle( 47 | // includeFontPadding = false 48 | // ), 49 | ) 50 | when (LocalDirection.current) { 51 | Direction.None -> UnscaledText(text, actualStyle, modifier.fillMaxSize().background(LocalStyle.current.backgroundColor)) 52 | Direction.Horizontal -> AutoFitTextVertically(text, actualStyle, modifier.fillMaxHeight().background(LocalStyle.current.backgroundColor)) 53 | Direction.Vertical -> AutoFitTextHorizontally(text, actualStyle, modifier.fillMaxWidth().background(LocalStyle.current.backgroundColor)) 54 | } 55 | } 56 | 57 | @Composable 58 | fun UnscaledText( 59 | text: String, 60 | style: TextStyle, 61 | modifier: Modifier = Modifier, 62 | ) { 63 | BoxWithConstraints( 64 | modifier = modifier, 65 | contentAlignment = Alignment.Center, 66 | ) { 67 | BasicText( 68 | text = text, 69 | style = style.copy( 70 | fontSize = maxHeight.toSp().times(0.1f) 71 | ), 72 | ) 73 | } 74 | } 75 | 76 | @Composable 77 | fun AutoFitTextVertically( 78 | text: String, 79 | style: TextStyle, 80 | modifier: Modifier = Modifier, 81 | ) { 82 | BoxWithConstraints(modifier = modifier) { 83 | BasicText( 84 | text = text, 85 | modifier = Modifier.fillMaxHeight(), 86 | style = style.copy( 87 | fontSize = maxHeight.toSp().times(0.65f) 88 | ), 89 | maxLines = 1, 90 | ) 91 | } 92 | } 93 | 94 | @Composable 95 | fun AutoFitTextHorizontally( 96 | text: String, 97 | style: TextStyle, 98 | modifier: Modifier = Modifier, 99 | ) { 100 | BoxWithConstraints(modifier = modifier) { 101 | var shrunkFontSize = 400.sp 102 | val calculateIntrinsics = @Composable { 103 | ParagraphIntrinsics( 104 | text = text, 105 | style = style.copy(fontSize = shrunkFontSize), 106 | density = LocalDensity.current, 107 | fontFamilyResolver = LocalFontFamilyResolver.current 108 | ) 109 | } 110 | 111 | var intrinsics = calculateIntrinsics() 112 | with(LocalDensity.current) { 113 | while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) { 114 | shrunkFontSize *= 0.9f 115 | intrinsics = calculateIntrinsics() 116 | } 117 | } 118 | 119 | BasicText( 120 | text = text, 121 | modifier = Modifier.fillMaxWidth(), 122 | style = style.copy(fontSize = shrunkFontSize), 123 | maxLines = 1, 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/UnknownItem.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.displayer.display.Padding 5 | import com.displayer.display.Style 6 | 7 | data class UnknownItem( 8 | override val style: Style? = null, 9 | override val padding: Padding, 10 | ) : Item() 11 | 12 | @Composable 13 | fun UnknownItemUi() { 14 | } 15 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/item/Weather.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.item 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Arrangement.spacedBy 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.rotate 19 | import androidx.compose.ui.graphics.ColorFilter 20 | import androidx.compose.ui.layout.ContentScale 21 | import androidx.compose.ui.text.TextStyle 22 | import androidx.compose.ui.text.font.FontWeight 23 | import com.displayer.display.Padding 24 | import com.displayer.display.Style 25 | import com.displayer.platform.formatTime 26 | import com.displayer.platform.getIcon 27 | import com.displayer.toSp 28 | import com.displayer.ui.Direction 29 | import com.displayer.ui.Icon 30 | import com.displayer.ui.LocalDimensions 31 | import com.displayer.ui.LocalDirection 32 | import com.displayer.ui.LocalLocale 33 | import com.displayer.ui.LocalStyle 34 | import com.displayer.ui.Text 35 | import com.displayer.weather.WeatherData 36 | import kotlinx.coroutines.flow.Flow 37 | import kotlinx.datetime.Clock 38 | import kotlinx.datetime.Instant 39 | import net.sergeych.sprintf.format 40 | import kotlin.math.roundToInt 41 | 42 | data class Weather( 43 | val observeCurrentWeather: () -> Flow>, 44 | override val style: Style? = null, 45 | override val padding: Padding, 46 | ) : Item() 47 | 48 | @Composable 49 | fun WeatherUi(item: Weather) { 50 | val weatherData by item.observeCurrentWeather().collectAsState(null) 51 | when (LocalDirection.current) { 52 | Direction.None -> weatherData?.run { WeatherItemOther(this) } 53 | Direction.Horizontal -> weatherData?.firstOrNull()?.run { WeatherItemRow(this) } 54 | Direction.Vertical -> weatherData?.firstOrNull()?.run { WeatherItemColumn(this, Arrangement.Top) } 55 | } 56 | } 57 | 58 | @Composable 59 | fun WeatherItemOther(data: List) { 60 | CompositionLocalProvider( 61 | LocalDirection provides Direction.Vertical, 62 | ) { 63 | val locale = LocalLocale.current 64 | Row { 65 | Spacer(modifier = Modifier.weight(10f)) 66 | data.getOrNull(0)?.let { data -> 67 | Box(modifier = Modifier.weight(25f)) { 68 | WeatherItemColumn(data, title = formatTime(Clock.System.now(), locale)) 69 | } 70 | } 71 | Spacer(modifier = Modifier.weight(15f)) 72 | data.getOrNull(1)?.let { data -> 73 | Box(modifier = Modifier.weight(25f)) { 74 | WeatherItemColumn(data, title = formatTime(Instant.fromEpochSeconds(data.timeStamp), locale)) 75 | } 76 | } 77 | Spacer(modifier = Modifier.weight(15f)) 78 | data.getOrNull(2)?.let { data -> 79 | Box(modifier = Modifier.weight(25f)) { 80 | WeatherItemColumn(data, title = formatTime(Instant.fromEpochSeconds(data.timeStamp), locale)) 81 | } 82 | } 83 | Spacer(modifier = Modifier.weight(10f)) 84 | } 85 | } 86 | } 87 | 88 | @Composable 89 | fun WeatherItemRow(data: WeatherData) { 90 | Row( 91 | horizontalArrangement = spacedBy(LocalDimensions.current.baseUnit), 92 | verticalAlignment = Alignment.CenterVertically, 93 | ) { 94 | AutoFitText(text = "%d°".format(data.temperature.roundToInt())) 95 | Image( 96 | painter = data.icon, 97 | contentDescription = null, 98 | modifier = Modifier.fillMaxHeight(0.8f), 99 | colorFilter = ColorFilter.tint(LocalStyle.current.contentColor), 100 | contentScale = ContentScale.FillHeight, 101 | ) 102 | data.description?.run { 103 | AutoFitText(text = this) 104 | } 105 | Image( 106 | painter = getIcon(Icon.WindDirection), 107 | contentDescription = null, 108 | modifier = Modifier.fillMaxHeight(0.7f).rotate(180f + (data.windDegrees)), 109 | colorFilter = ColorFilter.tint(LocalStyle.current.contentColor), 110 | contentScale = ContentScale.FillHeight, 111 | ) 112 | AutoFitText(text = data.formatWindSpeed()) 113 | } 114 | } 115 | 116 | @Composable 117 | fun WeatherItemColumn(data: WeatherData, verticalArrangement: Arrangement.Vertical = spacedBy(LocalDimensions.current.baseUnit), title: String? = null) { 118 | Column( 119 | modifier = Modifier.fillMaxWidth(), 120 | horizontalAlignment = Alignment.CenterHorizontally, 121 | verticalArrangement = verticalArrangement, 122 | ) { 123 | val textStyle = TextStyle.Default.copy(fontSize = (LocalDimensions.current.fontSize * 0.8f).toSp()) 124 | val textStyleLarge = TextStyle.Default.copy(fontSize = (LocalDimensions.current.fontSize * 1.8f).toSp()) 125 | title?.run { 126 | Text(this, style = TextStyle.Default.copy(fontWeight = FontWeight.Light, fontSize = (LocalDimensions.current.fontSize * 1.4f).toSp())) 127 | } 128 | Text(text = data.formatTemperature(), style = textStyleLarge) 129 | Image( 130 | painter = data.icon, 131 | contentDescription = null, 132 | modifier = Modifier.fillMaxWidth(0.5f), 133 | colorFilter = ColorFilter.tint(LocalStyle.current.contentColor), 134 | contentScale = ContentScale.FillWidth, 135 | ) 136 | Row( 137 | verticalAlignment = Alignment.CenterVertically, 138 | ) { 139 | Image( 140 | painter = getIcon(Icon.WindDirection), 141 | contentDescription = null, 142 | modifier = Modifier.fillMaxWidth(0.2f).rotate(180f + (data.windDegrees)), 143 | colorFilter = ColorFilter.tint(LocalStyle.current.contentColor), 144 | contentScale = ContentScale.FillHeight, 145 | ) 146 | Text(text = data.formatWindSpeed(), style = textStyle.copy(fontWeight = FontWeight.Light)) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/parser/Dto.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.parser 2 | 3 | import com.displayer.platform.getDefaultCountry 4 | import com.displayer.platform.getDefaultLanguage 5 | import com.displayer.weather.WeatherData 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class DisplayFile( 11 | val parameters: ParametersDto? = null, 12 | val styles: List = emptyList(), 13 | val styleId: String? = null, 14 | val center: ContainerDto, 15 | val left: ContainerDto? = null, 16 | val bottom: ContainerDto? = null, 17 | val bottomHeight: Float? = null, 18 | ) 19 | 20 | /* 21 | CONTAINERS 22 | */ 23 | 24 | @Serializable 25 | sealed class ContainerDto { 26 | abstract val padding: PaddingDto? 27 | abstract val styleId: String? 28 | abstract val items: List 29 | abstract val divider: ItemDto? 30 | } 31 | 32 | @Serializable 33 | @SerialName("stack") 34 | data class StackDto( 35 | override val padding: PaddingDto? = null, 36 | override val styleId: String? = null, 37 | override val items: List = emptyList(), 38 | override val divider: ItemDto? = null, 39 | val autoAdvanceInSeconds: Int? = null, 40 | ) : ContainerDto() 41 | 42 | @Serializable 43 | @SerialName("column") 44 | data class ColumnDto( 45 | override val padding: PaddingDto? = null, 46 | override val styleId: String? = null, 47 | override val items: List = emptyList(), 48 | override val divider: ItemDto? = null, 49 | val spacing: Float? = null, 50 | val scrollSpeedInSeconds: Float? = null, 51 | ) : ContainerDto() 52 | 53 | @Serializable 54 | @SerialName("row") 55 | data class RowDto( 56 | override val padding: PaddingDto? = null, 57 | override val styleId: String? = null, 58 | override val items: List = emptyList(), 59 | override val divider: ItemDto? = null, 60 | val spacing: Float? = null, 61 | val scrollSpeedInSeconds: Float? = null, 62 | ) : ContainerDto() 63 | 64 | /* 65 | ITEMS 66 | */ 67 | 68 | @Serializable 69 | sealed class ItemDto { 70 | abstract val styleId: String? 71 | abstract val padding: PaddingDto? 72 | } 73 | 74 | @Serializable 75 | @SerialName("unknown") 76 | data class UnknownDto( 77 | override val styleId: String? = null, 78 | override val padding: PaddingDto? = null, 79 | ) : ItemDto() 80 | 81 | @Serializable 82 | @SerialName("clock") 83 | data class ClockDto( 84 | override val styleId: String? = null, 85 | override val padding: PaddingDto? = null, 86 | ) : ItemDto() 87 | 88 | @Serializable 89 | @SerialName("text") 90 | data class TextDto( 91 | val text: String, 92 | override val styleId: String? = null, 93 | override val padding: PaddingDto? = null, 94 | ) : ItemDto() 95 | 96 | @Serializable 97 | @SerialName("image") 98 | data class ImageDto( 99 | val url: String, 100 | override val styleId: String? = null, 101 | override val padding: PaddingDto? = null, 102 | val scale: ScaleDto? = null, 103 | ) : ItemDto() 104 | 105 | @Serializable 106 | @SerialName("random") 107 | data class RandomDto( 108 | override val styleId: String? = null, 109 | override val padding: PaddingDto? = null, 110 | val items: List = emptyList(), 111 | ) : ItemDto() 112 | 113 | @Serializable 114 | @SerialName("weather") 115 | data class WeatherDto( 116 | val weatherData: WeatherData? = null, 117 | override val styleId: String? = null, 118 | override val padding: PaddingDto? = null, 119 | ) : ItemDto() 120 | 121 | @Serializable 122 | @SerialName("drink") 123 | data class DrinkDto( 124 | val image: String, 125 | val text: String, 126 | override val styleId: String? = null, 127 | override val padding: PaddingDto? = null, 128 | val spacing: Float? = null, 129 | ) : ItemDto() 130 | 131 | @Serializable 132 | @SerialName("social") 133 | data class SocialDto( 134 | val app: SocialApp, 135 | val account: String, 136 | val text: String? = null, 137 | override val styleId: String? = null, 138 | override val padding: PaddingDto? = null, 139 | ) : ItemDto() 140 | 141 | @Serializable 142 | data class StyleDto( 143 | val id: String, 144 | val contentColor: String? = null, 145 | val backgroundColor: String? = null, 146 | ) 147 | 148 | @Serializable 149 | data class PaddingDto( 150 | val horizontal: Float = 0f, 151 | val vertical: Float = 0f, 152 | ) 153 | 154 | @Serializable 155 | data class ParametersDto( 156 | val refreshInMinutes: Int = 0, 157 | val language: String = getDefaultLanguage(), // ISO 639-1 158 | val country: String = getDefaultCountry(), // ISO 3166 159 | val zip: String? = null, 160 | val units: Units? = null, 161 | ) 162 | 163 | enum class Units { 164 | @SerialName("metric") 165 | Metric, 166 | 167 | @SerialName("imperial") 168 | Imperial, 169 | } 170 | 171 | enum class ScaleDto { 172 | @SerialName("crop") 173 | Crop, 174 | 175 | @SerialName("fit") 176 | Fit, 177 | } 178 | 179 | enum class SocialApp { 180 | @SerialName("facebook") 181 | Facebook, 182 | 183 | @SerialName("instagram") 184 | Instagram, 185 | 186 | @SerialName("tiktok") 187 | Tiktok, 188 | 189 | @SerialName("youtube") 190 | YouTube, 191 | 192 | @SerialName("snapchat") 193 | Snapchat, 194 | } 195 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/parser/Message.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.parser 2 | 3 | data class Message( 4 | val severity: Severity, 5 | val message: String, 6 | ) 7 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/displayer/display/parser/Parser.kt: -------------------------------------------------------------------------------- 1 | package com.displayer.display.parser 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.layout.ContentScale 5 | import com.displayer.config.Parameters 6 | import com.displayer.display.Padding 7 | import com.displayer.display.Style 8 | import com.displayer.display.container.ColumnLayout 9 | import com.displayer.display.container.Container 10 | import com.displayer.display.container.Empty 11 | import com.displayer.display.container.RowLayout 12 | import com.displayer.display.container.StackLayout 13 | import com.displayer.display.item.Clock 14 | import com.displayer.display.item.DrinkItem 15 | import com.displayer.display.item.Image 16 | import com.displayer.display.item.Item 17 | import com.displayer.display.item.RandomItem 18 | import com.displayer.display.item.SocialItem 19 | import com.displayer.display.item.TextItem 20 | import com.displayer.display.item.UnknownItem 21 | import com.displayer.display.item.Weather 22 | import com.displayer.parse 23 | import com.displayer.weather.WeatherData 24 | import kotlinx.collections.immutable.toImmutableList 25 | import kotlinx.coroutines.flow.Flow 26 | 27 | object Parser { 28 | 29 | fun parseStyle(dto: StyleDto): Result