diff --git a/gui/.gitignore b/gui/.gitignore
new file mode 100644
index 0000000..77c21ba
--- /dev/null
+++ b/gui/.gitignore
@@ -0,0 +1,11 @@
+*.iml
+.kotlin
+build/
+.DS_Store
+captures
+**/module.xcodeproj/*
+!**/module.xcodeproj/project.pbxproj
+!**/module.xcodeproj/xcshareddata/
+!**/module.xcodeproj/project.xcworkspace/
+**/xcshareddata/WorkspaceSettings.xcsettings
+xcuserdata
diff --git a/gui/.idea/.gitignore b/gui/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/gui/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/gui/.idea/.name b/gui/.idea/.name
new file mode 100644
index 0000000..e7b569d
--- /dev/null
+++ b/gui/.idea/.name
@@ -0,0 +1 @@
+Nomadic Weather
\ No newline at end of file
diff --git a/gui/.idea/AndroidProjectSystem.xml b/gui/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..b9f359c
--- /dev/null
+++ b/gui/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/amper.xml b/gui/.idea/amper.xml
new file mode 100644
index 0000000..6cd6400
--- /dev/null
+++ b/gui/.idea/amper.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/caches/deviceStreaming.xml b/gui/.idea/caches/deviceStreaming.xml
new file mode 100644
index 0000000..41e7bdd
--- /dev/null
+++ b/gui/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,2446 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/migrations.xml b/gui/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/gui/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/misc.xml b/gui/.idea/misc.xml
new file mode 100644
index 0000000..c2b3ddc
--- /dev/null
+++ b/gui/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/modules.xml b/gui/.idea/modules.xml
new file mode 100644
index 0000000..684c12b
--- /dev/null
+++ b/gui/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/.idea/vcs.xml b/gui/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/gui/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/README.md b/gui/README.md
index e69de29..c04d2f7 100644
--- a/gui/README.md
+++ b/gui/README.md
@@ -0,0 +1,22 @@
+This is a Kotlin Multiplatform project targeting Android, Desktop (JVM), built with the [Kotlin Toolchain](https://kotlin-toolchain.org/dev/).
+
+- [/androidApp](./androidApp) contains the Android application.
+- [/desktopApp](./desktopApp) contains the desktop (JVM) application.
+- [/shared](./shared) holds the code shared across your applications — Compose UI, business logic, and platform-specific implementations. [src](./shared/src) is for common code; the sibling `src@` folders (for example `src@android`) hold code compiled only for the platform named in the folder.
+
+The `kotlin` (macOS/Linux) and `kotlin.bat` (Windows) scripts in the project root are self-bootstrapping wrappers for the Kotlin Toolchain: they download the pinned toolchain version on first use, so no separate installation is required. Build the whole project with `./kotlin build`.
+
+### Running
+
+Use the run configurations provided by the run widget in your IDE's toolbar. You can also run each module from the command line:
+
+- Android app: `./kotlin run -m androidApp`
+- Desktop app: `./kotlin run -m desktopApp`
+
+### Testing
+
+Run the project's tests with `./kotlin test`, or `./kotlin test -m ` for a single module.
+
+---
+
+Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), [Kotlin Toolchain](https://kotlin-toolchain.org/dev/), [Compose Multiplatform](https://kotlinlang.org/compose-multiplatform/).
diff --git a/gui/androidApp/module.yaml b/gui/androidApp/module.yaml
new file mode 100644
index 0000000..ed0a326
--- /dev/null
+++ b/gui/androidApp/module.yaml
@@ -0,0 +1,13 @@
+product: android/app
+
+dependencies:
+ - //shared
+ - $libs.androidx.activity.compose
+ - $compose.preview
+ - $compose.uiTooling
+
+settings:
+ compose: enabled
+ android:
+ namespace: com.hyperling.weather
+ applicationId: com.hyperling.weather
diff --git a/gui/androidApp/proguard-rules.pro b/gui/androidApp/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/gui/androidApp/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/gui/androidApp/res/drawable-v24/ic_launcher_foreground.xml b/gui/androidApp/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/gui/androidApp/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/androidApp/res/drawable/ic_launcher_background.xml b/gui/androidApp/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..e93e11a
--- /dev/null
+++ b/gui/androidApp/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher.xml b/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher_round.xml b/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/gui/androidApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/androidApp/res/mipmap-hdpi/ic_launcher.png b/gui/androidApp/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a571e60
Binary files /dev/null and b/gui/androidApp/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/gui/androidApp/res/mipmap-hdpi/ic_launcher_round.png b/gui/androidApp/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..61da551
Binary files /dev/null and b/gui/androidApp/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/gui/androidApp/res/mipmap-mdpi/ic_launcher.png b/gui/androidApp/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c41dd28
Binary files /dev/null and b/gui/androidApp/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/gui/androidApp/res/mipmap-mdpi/ic_launcher_round.png b/gui/androidApp/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..db5080a
Binary files /dev/null and b/gui/androidApp/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/gui/androidApp/res/mipmap-xhdpi/ic_launcher.png b/gui/androidApp/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..6dba46d
Binary files /dev/null and b/gui/androidApp/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/gui/androidApp/res/mipmap-xhdpi/ic_launcher_round.png b/gui/androidApp/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..da31a87
Binary files /dev/null and b/gui/androidApp/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/gui/androidApp/res/mipmap-xxhdpi/ic_launcher.png b/gui/androidApp/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..15ac681
Binary files /dev/null and b/gui/androidApp/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/gui/androidApp/res/mipmap-xxhdpi/ic_launcher_round.png b/gui/androidApp/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b216f2d
Binary files /dev/null and b/gui/androidApp/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher.png b/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f25a419
Binary files /dev/null and b/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher_round.png b/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e96783c
Binary files /dev/null and b/gui/androidApp/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/gui/androidApp/res/values/strings.xml b/gui/androidApp/res/values/strings.xml
new file mode 100644
index 0000000..9e2dd77
--- /dev/null
+++ b/gui/androidApp/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ NomadicWeather
+
diff --git a/gui/androidApp/src/AndroidManifest.xml b/gui/androidApp/src/AndroidManifest.xml
new file mode 100644
index 0000000..d68e1ba
--- /dev/null
+++ b/gui/androidApp/src/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gui/androidApp/src/MainActivity.kt b/gui/androidApp/src/MainActivity.kt
new file mode 100644
index 0000000..728fdbe
--- /dev/null
+++ b/gui/androidApp/src/MainActivity.kt
@@ -0,0 +1,24 @@
+package com.hyperling.weather
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ setContent {
+ App()
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AppAndroidPreview() {
+ App()
+}
diff --git a/gui/desktopApp/module.yaml b/gui/desktopApp/module.yaml
new file mode 100644
index 0000000..5e87a34
--- /dev/null
+++ b/gui/desktopApp/module.yaml
@@ -0,0 +1,13 @@
+product: jvm/app
+
+dependencies:
+ - //shared
+ - $compose.desktop.currentOs
+ - $compose.preview
+ - $libs.kotlinx.coroutines.swing
+ - $libs.logback.classic
+
+settings:
+ compose: enabled
+ jvm:
+ mainClass: com.hyperling.weather.MainKt
diff --git a/gui/desktopApp/resources/logback.xml b/gui/desktopApp/resources/logback.xml
new file mode 100644
index 0000000..42a3539
--- /dev/null
+++ b/gui/desktopApp/resources/logback.xml
@@ -0,0 +1,10 @@
+
+
+
+ %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
diff --git a/gui/desktopApp/src/main.kt b/gui/desktopApp/src/main.kt
new file mode 100644
index 0000000..125e9b6
--- /dev/null
+++ b/gui/desktopApp/src/main.kt
@@ -0,0 +1,10 @@
+package com.hyperling.weather
+
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+
+fun main() = application {
+ Window(onCloseRequest = ::exitApplication, title = "NomadicWeather") {
+ App()
+ }
+}
diff --git a/gui/kotlin b/gui/kotlin
new file mode 100755
index 0000000..5ef1dee
--- /dev/null
+++ b/gui/kotlin
@@ -0,0 +1,292 @@
+#!/bin/sh
+
+#
+# Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+#
+
+# Possible environment variables:
+# KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from.
+# default: https://packages.jetbrains.team/maven/p/amper/amper
+# KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the JRE to run the Kotlin CLI
+# default: https:/
+# KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution
+# KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation)
+# KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application)
+# KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value
+
+set -e -u
+
+# The version of the Kotlin Toolchain (and CLI) distribution to provision and use
+kotlin_cli_version=0.13.0
+# Establish chain of trust from here by specifying exact checksum of Kotlin Toolchain (and CLI) distribution to be run
+kotlin_cli_sha256=fa82b9872dcfa04d26c8f800f33d597c99fbffd747a0e9bbce3a9e4424b3a588
+
+KOTLIN_CLI_DOWNLOAD_ROOT="${KOTLIN_CLI_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}"
+
+die() {
+ echo >&2
+ echo "$@" >&2
+ echo >&2
+ exit 1
+}
+
+# usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE
+# $1 SOURCE_MONIKER (e.g. url)
+# $2 FILE
+# $3 SHA hex string
+# $4 SHA size in bits (256, 512, ...)
+check_sha() {
+ sha_size=$4
+ if command -v shasum >/dev/null 2>&1; then
+ echo "$3 *$2" | shasum -a "$sha_size" --status -c || {
+ die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')"
+ }
+ return 0
+ fi
+
+ shaNsumCommand="sha${sha_size}sum"
+ if command -v "$shaNsumCommand" >/dev/null 2>&1; then
+ # discard the output as sha*sum may print redundant warnings in some versions
+ echo "$3 *$2" | $shaNsumCommand -w -c >/dev/null 2>&1 || {
+ die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')"
+ }
+ return 0
+ fi
+
+ echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them"
+ return 1
+}
+
+
+download_and_extract() {
+ moniker="$1"
+ file_url="$2"
+ file_sha="$3"
+ sha_size="$4"
+ cache_dir="$5"
+ extract_dir="$6"
+ show_banner_on_cache_miss="$7"
+
+ if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then
+ # Everything is up-to-date in $extract_dir, do nothing
+ return 0;
+ fi
+
+ mkdir -p "$cache_dir"
+
+ # Take a lock for the download of this file
+ short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh
+ download_lock_file="$cache_dir/download-${short_sha}.lock"
+ process_lock_file="$cache_dir/download-${short_sha}.$$.lock"
+ temp_file="$cache_dir/download-${short_sha}.$$.bin"
+ echo $$ >"$process_lock_file"
+ while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do
+ lock_owner=$(cat "$download_lock_file" 2>/dev/null || true)
+ # We use `kill -0` instead of `ps -p` as the first one is more portable
+ if [ -n "$lock_owner" ] && kill -0 "$lock_owner" >/dev/null; then
+ echo "Another Kotlin CLI instance (pid $lock_owner) is downloading $moniker. Awaiting the result..."
+ sleep 1
+ elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then
+ rm -f "$download_lock_file"
+ # We don't want to simply loop again here, because multiple concurrent processes may face this at the same time,
+ # which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just
+ # ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically
+ # reduced because it would involve 4 processes with perfect timing. We can revisit this later.
+ die "Another Kotlin CLI instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again."
+ fi
+ done
+
+
+ # An extraction left half-done must never keep the .flag file, which marks $extract_dir as complete: subsequent runs
+ # would skip the extraction and use the broken directory.
+ cleanup_incomplete_provisioning() {
+ rm -f "$download_lock_file" "$temp_file"
+ rm -rf "$extract_dir"
+ }
+ trap cleanup_incomplete_provisioning EXIT
+ rm -f "$process_lock_file"
+
+ unlock_and_cleanup() {
+ rm -f "$download_lock_file"
+ trap - EXIT
+ return 0
+ }
+
+ if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then
+ # Everything is up-to-date in $extract_dir, just release the lock
+ unlock_and_cleanup
+ return 0;
+ fi
+
+ if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${KOTLIN_CLI_NO_WELCOME_BANNER:-}" ]; then
+ echo
+ cat </dev/null 2>&1; then
+ if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi
+ # shellcheck disable=SC2086
+ curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url"
+ elif command -v wget >/dev/null 2>&1; then
+ if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi
+ wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url"
+ else
+ die "ERROR: Please install 'wget' or 'curl', as one of them is required to download $moniker"
+ fi
+
+ check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size"
+
+ rm -rf "$extract_dir"
+ mkdir -p "$extract_dir"
+
+ case "$file_url" in
+ *".zip")
+ if command -v unzip >/dev/null 2>&1; then
+ unzip -q "$temp_file" -d "$extract_dir"
+ else
+ die "ERROR: Please install 'unzip', which is required to extract $moniker"
+ fi ;;
+ *)
+ if command -v tar >/dev/null 2>&1; then
+ tar -x -f "$temp_file" -C "$extract_dir"
+ else
+ die "ERROR: Please install 'tar', which is required to extract $moniker"
+ fi ;;
+ esac
+
+ rm -f "$temp_file"
+
+ echo "$file_sha" >"$extract_dir/.flag"
+
+ # Unlock and cleanup the lock file
+ unlock_and_cleanup
+
+ echo "Download complete."
+ echo
+}
+
+
+# ********** Project-local version detection **********
+
+# 1. Search upwards for an executable `amper` file and/or `project.yaml`
+# Sets wrapper_script to the found wrapper path, or empty string if not found.
+find_project_context() {
+ wrapper_script=""
+ this_script="$(realpath "$0")"
+ project_dir=$(pwd)
+ while [ "$project_dir" != "/" ] && [ -n "$project_dir" ]; do
+ wrapper_candidate="$project_dir/kotlin"
+ if [ "$this_script" = "$wrapper_candidate" ]; then
+ # Found itself (local wrapper case), no need to update any version or search further.
+ return 1
+ fi
+
+ if [ -f "$wrapper_candidate" ] && [ -x "$wrapper_candidate" ]; then
+ # Found the wrapper — check that a project context exists alongside it
+ if [ -f "$project_dir/project.yaml" ] || [ -f "$project_dir/module.yaml" ]; then
+ wrapper_script="$wrapper_candidate"
+ return 0
+ else
+ echo "WARNING: Found wrapper script '$wrapper_candidate', but no project.yaml or module.yaml near it. Skipping." >&2
+ # Continue the search
+ fi
+ elif [ -f "$project_dir/project.yaml" ]; then
+ # Found project.yaml but no executable wrapper alongside it
+ echo "WARNING: Found a project.yaml in '$project_dir', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version." >&2
+ return 1
+ fi
+
+ project_dir=$(dirname "$project_dir")
+ done
+ # Do not check root '/' - it's an unlikely candidate for a project
+
+ return 1
+}
+
+parse_project_context() {
+ # Parse kotlin_cli_version and kotlin_cli_sha256 from "$wrapper_script" without executing it.
+ parsed_kotlin_cli_version=$(
+ sed -n 's/^kotlin_cli_version=\([A-Za-z0-9._+-]\{1,\}\)[[:space:]]*$/\1/p' "$wrapper_script" \
+ | head -n 1
+ )
+ parsed_kotlin_cli_sha256=$(
+ sed -n 's/^kotlin_cli_sha256=\([0-9a-fA-F]\{64\}\)[[:space:]]*$/\1/p' "$wrapper_script" \
+ | head -n 1
+ )
+
+ if [ -z "$parsed_kotlin_cli_version" ]; then
+ echo "ERROR: Suspicious local wrapper script: failed to detect the distribution version in '$wrapper_script'" >&2
+ return 1
+ fi
+ if [ -z "$parsed_kotlin_cli_sha256" ]; then
+ echo "ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '$wrapper_script'" >&2
+ return 1
+ fi
+
+ # overwrite builtin values and proceed
+ kotlin_cli_version=$parsed_kotlin_cli_version
+ kotlin_cli_sha256=$parsed_kotlin_cli_sha256
+ return 0
+}
+
+if [ -z "${KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION:-}" ]; then
+ find_project_context && parse_project_context
+fi
+
+# ********** System detection **********
+
+kernelName=$(uname -s)
+case "$kernelName" in
+ Darwin* )
+ default_kotlin_cli_cache_dir="$HOME/Library/Caches/JetBrains/Kotlin/cli"
+ ;;
+ Linux* )
+ default_kotlin_cli_cache_dir="$HOME/.cache/JetBrains/Kotlin/cli"
+ ;;
+ CYGWIN* | MSYS* | MINGW* )
+ if command -v cygpath >/dev/null 2>&1; then
+ default_kotlin_cli_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Kotlin\cli")
+ else
+ die "The 'cypath' command is not available, but the Kotlin CLI needs it. Use kotlin.bat instead, or try a Cygwin or MSYS environment."
+ fi
+ ;;
+ *)
+ die "Unsupported platform $kernelName"
+ ;;
+esac
+
+kotlin_cli_cache_dir="${KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:-$default_kotlin_cli_cache_dir}"
+
+# ********** Provision the Kotlin Toolchain distribution **********
+
+kotlin_cli_url="$KOTLIN_CLI_DOWNLOAD_ROOT/org/jetbrains/kotlin/kotlin-cli/$kotlin_cli_version/kotlin-cli-$kotlin_cli_version-dist.tgz"
+kotlin_cli_target_dir="$kotlin_cli_cache_dir/kotlin-cli-$kotlin_cli_version"
+download_and_extract "Kotlin Toolchain distribution v$kotlin_cli_version" "$kotlin_cli_url" "$kotlin_cli_sha256" 256 "$kotlin_cli_cache_dir" "$kotlin_cli_target_dir" "true"
+
+# ********** Launch the Kotlin CLI **********
+
+launcher_script="$kotlin_cli_target_dir/bin/launcher.sh"
+
+KOTLIN_CLI_WRAPPER_PATH="$(realpath "$0")" \
+exec /bin/sh "$launcher_script" "$@"
diff --git a/gui/kotlin.bat b/gui/kotlin.bat
new file mode 100644
index 0000000..12e3997
--- /dev/null
+++ b/gui/kotlin.bat
@@ -0,0 +1,280 @@
+@echo off
+
+@rem
+@rem Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+@rem
+
+@rem Possible environment variables:
+@rem KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from
+@rem default: https://packages.jetbrains.team/maven/p/amper/amper
+@rem KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the Kotlin CLI JRE from.
+@rem default: https:/
+@rem KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution
+@rem KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation)
+@rem KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application)
+@rem KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value
+
+setlocal
+
+@rem The version of the Kotlin Toolchain distribution to provision and use
+set kotlin_cli_version=0.13.0
+@rem Establish chain of trust from here by specifying the exact checksum of the Kotlin Toolchain distribution to be run
+set kotlin_cli_sha256=fa82b9872dcfa04d26c8f800f33d597c99fbffd747a0e9bbce3a9e4424b3a588
+
+if not defined KOTLIN_CLI_DOWNLOAD_ROOT set KOTLIN_CLI_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper
+if not defined KOTLIN_CLI_BOOTSTRAP_CACHE_DIR set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Kotlin\cli
+@rem remove trailing \ if present
+if [%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~-1%] EQU [\] set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~0,-1%
+
+goto :after_function_declarations
+
+REM ********** Download and extract any zip or .tar.gz archive **********
+
+:download_and_extract
+setlocal
+
+set moniker=%~1
+set url=%~2
+set target_dir=%~3
+set sha=%~4
+set sha_size=%~5
+set show_banner_on_cache_miss=%~6
+
+setlocal enableDelayedExpansion
+
+set flag_file=%target_dir%\.flag
+if exist "%flag_file%" (
+ set /p current_flag=<"%flag_file%"
+ if "!current_flag!" == "%sha%" exit /b
+)
+
+set NL=^
+
+
+@rem two empty lines required above for the NL character
+
+@rem We have to build the welcome banner here as an env var because we
+@rem can't pass a multiline string through the single line powershell
+set welcome_banner=!NL! ^
+Welcome to !NL! ^
+ !NL! ^
+@@@ @@@@ @@@ @@@ !NL! ^
+@@@ @@@@ @@@ @@@ @@@ !NL! ^
+@@@ #@@@^" ,@@@ @@@ !NL! ^
+@@@ ,@@@%% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@, !NL! ^
+@@@ @@@@ @@@@@%%^"%%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%%%@@@@@ !NL! ^
+@@@@@@%% @@@%% %%@@@ @@@ @@@ @@@ @@@@ %%@@%% !NL! ^
+@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^
+@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^
+@@@ *@@@%% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@ !NL! ^
+@@@ %%@@@= %%@@@@*,*@@@@%% @@@@### @@@ @@@ @@@ @@@ !NL! ^
+@@@ @@@@ ^"@@@@@@@^" %%@@@@@ @@@ @@@ @@@ @@@ !NL!
+
+@rem This multiline string is actually passed as a single line to powershell, meaning #-comments are not possible.
+@rem So here are a few comments about the code below:
+@rem - we need to support both .zip and .tar.gz archives (for the Kotlin Toolchain distribution and the JRE)
+@rem - tar should be present in all Windows machines since 2018 (and usable from both cmd and powershell)
+@rem - tar requires the destination dir to exist
+@rem - native commands like tar don't raise PowerShell errors on failure, so we have to check $LASTEXITCODE.
+@rem The extraction must never be left half-done with the .flag file in place, because the .flag file marks the
+@rem target dir as complete, and subsequent runs would then skip the extraction and use the broken directory.
+@rem - We use (New-Object Net.WebClient).DownloadFile instead of Invoke-WebRequest for performance. See the issue
+@rem https://github.com/PowerShell/PowerShell/issues/16914, which is still not fixed in Windows PowerShell 5.1
+@rem - DownloadFile requires the directories in the destination file's path to exist
+set download_and_extract_ps1= ^
+Set-StrictMode -Version 3.0; ^
+$ErrorActionPreference = 'Stop'; ^
+ ^
+$temp_file = $null; ^
+$createdNew = $false; ^
+$lock = New-Object System.Threading.Mutex($true, ('Global\kotlin-cli-bootstrap.' + '%target_dir%'.GetHashCode().ToString()), [ref]$createdNew); ^
+if (-not $createdNew) { ^
+ Write-Host 'Another Kotlin CLI instance is bootstrapping. Waiting for our turn...'; ^
+ [void]$lock.WaitOne(); ^
+} ^
+ ^
+try { ^
+ if ((Get-Content '%flag_file%' -ErrorAction Ignore) -ne '%sha%') { ^
+ if (('%show_banner_on_cache_miss%' -eq 'true') -and [string]::IsNullOrEmpty('%KOTLIN_CLI_NO_WELCOME_BANNER%')) { ^
+ Write-Host \"$env:welcome_banner\"; ^
+ Write-Host ''; ^
+ Write-Host 'This is the first run of the Kotlin CLI v%kotlin_cli_version%, so we need to download the Kotlin Toolchain.'; ^
+ Write-Host 'Please give us a few seconds now, subsequent runs will be faster.'; ^
+ Write-Host ''; ^
+ } ^
+ $temp_file = '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\' + [System.IO.Path]::GetRandomFileName(); ^
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ^
+ Write-Host 'Downloading %moniker%...'; ^
+ [void](New-Item '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%' -ItemType Directory -Force); ^
+ if (Get-Command curl.exe -errorAction SilentlyContinue) { ^
+ curl.exe -L --silent --show-error --fail --output $temp_file '%url%'; ^
+ } else { ^
+ (New-Object Net.WebClient).DownloadFile('%url%', $temp_file); ^
+ } ^
+ ^
+ $actualSha = (Get-FileHash -Algorithm SHA%sha_size% -Path $temp_file).Hash.ToString(); ^
+ if ($actualSha -ne '%sha%') { ^
+ $writeErr = if ($Host.Name -eq 'ConsoleHost') { [Console]::Error.WriteLine } else { $host.ui.WriteErrorLine } ^
+ $writeErr.Invoke(\"ERROR: Checksum mismatch for $temp_file (downloaded from %url%): expected checksum %sha% but got $actualSha\"); ^
+ exit 1; ^
+ } ^
+ ^
+ if (Test-Path '%target_dir%') { ^
+ Remove-Item '%target_dir%' -Recurse; ^
+ } ^
+ try { ^
+ if ($temp_file -like '*.zip') { ^
+ Add-Type -A 'System.IO.Compression.FileSystem'; ^
+ [IO.Compression.ZipFile]::ExtractToDirectory($temp_file, '%target_dir%'); ^
+ } else { ^
+ [void](New-Item '%target_dir%' -ItemType Directory -Force); ^
+ tar -xzf $temp_file -C '%target_dir%'; ^
+ if ($LASTEXITCODE -ne 0) { ^
+ throw \"tar failed to extract %moniker% (exit code $LASTEXITCODE)\"; ^
+ } ^
+ } ^
+ } catch { ^
+ Remove-Item '%flag_file%' -Force -ErrorAction Ignore; ^
+ Remove-Item '%target_dir%' -Recurse -Force -ErrorAction Ignore; ^
+ throw; ^
+ } ^
+ ^
+ Set-Content '%flag_file%' -Value '%sha%'; ^
+ Write-Host 'Download complete.'; ^
+ Write-Host ''; ^
+ } ^
+} ^
+finally { ^
+ if ($temp_file) { ^
+ Remove-Item $temp_file -Force -ErrorAction Ignore; ^
+ } ^
+ $lock.ReleaseMutex(); ^
+}
+
+rem We reset the PSModulePath in case this batch script was called from PowerShell Core
+rem See https://github.com/PowerShell/PowerShell/issues/18108#issuecomment-2269703022
+set PSModulePath=
+set powershell=%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe
+"%powershell%" -NonInteractive -NoProfile -NoLogo -Command %download_and_extract_ps1%
+if errorlevel 1 exit /b 1
+exit /b 0
+
+:find_project_context
+@rem Search upwards for a kotlin.bat wrapper file and/or project.yaml
+@rem Sets wrapper_script to the found wrapper path, or empty string if not found.
+@rem Returns errorlevel 0 if a valid wrapper (that is not this script itself) was found, 1 otherwise.
+set wrapper_script=
+set this_script=%~f0
+set project_dir=%CD%
+
+:find_loop
+set wrapper_candidate=%project_dir%\kotlin.bat
+if "%this_script%"=="%wrapper_candidate%" (
+ @rem Found itself (local wrapper case), no need to update any version or search further.
+ exit /b 1
+)
+
+if exist "%wrapper_candidate%" (
+ @rem Found a wrapper — check that a project context exists alongside it
+ if exist "%project_dir%\project.yaml" (
+ set wrapper_script=%wrapper_candidate%
+ exit /b 0
+ )
+ if exist "%project_dir%\module.yaml" (
+ set wrapper_script=%wrapper_candidate%
+ exit /b 0
+ )
+ echo WARNING: Found wrapper script '%wrapper_candidate%', but no project.yaml or module.yaml near it. Skipping. >&2
+ @rem Continue the search
+ goto :find_next_parent
+)
+
+if exist "%project_dir%\project.yaml" (
+ @rem Found project.yaml but no wrapper alongside it
+ echo WARNING: Found a project.yaml in '%project_dir%', but the wrapper script is missing; using Kotlin Toolchain v%kotlin_cli_version%. >&2
+ exit /b 1
+)
+
+:find_next_parent
+@rem Move to parent directory
+for %%P in ("%project_dir%\..") do set parent_dir=%%~fP
+if "%parent_dir%"=="%project_dir%" (
+ @rem Reached the root, stop searching
+ exit /b 1
+)
+set project_dir=%parent_dir%
+goto :find_loop
+
+:parse_project_context
+@rem Parse kotlin_cli_version and kotlin_cli_sha256 from the found wrapper_script without executing it.
+set parsed_kotlin_cli_version=
+set parsed_kotlin_cli_sha256=
+
+for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_version=[A-Za-z0-9._+-]*$" "%wrapper_script%"') do (
+ if not defined parsed_kotlin_cli_version set parsed_kotlin_cli_version=%%A
+)
+for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_sha256=[0-9a-fA-F]*$" "%wrapper_script%"') do (
+ if not defined parsed_kotlin_cli_sha256 set parsed_kotlin_cli_sha256=%%A
+)
+
+if not defined parsed_kotlin_cli_version (
+ echo ERROR: Suspicious local wrapper script: failed to detect the distribution version in '%wrapper_script%' >&2
+ exit /b 1
+)
+if not defined parsed_kotlin_cli_sha256 (
+ echo ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '%wrapper_script%' >&2
+ exit /b 1
+)
+
+@rem Overwrite builtin values and proceed
+set kotlin_cli_version=%parsed_kotlin_cli_version%
+set kotlin_cli_sha256=%parsed_kotlin_cli_sha256%
+exit /b 0
+
+:fail
+echo ERROR: Kotlin CLI bootstrap failed, see errors above
+exit /b 1
+
+:after_function_declarations
+
+REM ********** Project-local version detection **********
+
+if defined KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION goto :after_local_version_detection
+
+call :find_project_context
+if errorlevel 1 goto :after_local_version_detection
+call :parse_project_context
+if errorlevel 1 goto fail
+:after_local_version_detection
+
+REM ********** Provision the Kotlin Toolchain distribution **********
+
+set kotlin_cli_url=%KOTLIN_CLI_DOWNLOAD_ROOT%/org/jetbrains/kotlin/kotlin-cli/%kotlin_cli_version%/kotlin-cli-%kotlin_cli_version%-dist.tgz
+set kotlin_cli_target_dir=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\kotlin-cli-%kotlin_cli_version%
+call :download_and_extract "Kotlin Toolchain distribution v%kotlin_cli_version%" "%kotlin_cli_url%" "%kotlin_cli_target_dir%" "%kotlin_cli_sha256%" "256" "true"
+if errorlevel 1 goto fail
+
+REM ********** Launch the Kotlin CLI **********
+
+rem Determine the correct busybox binary based on architecture
+if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
+ set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64a.exe
+) else if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
+ set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64u.exe
+) else (
+ echo Unsupported architecture %PROCESSOR_ARCHITECTURE% >&2
+ goto fail
+)
+
+rem We use busybox here because it doesn't reinterpret the user-passed command-line arguments (that we pass via %*).
+rem Also this way we can use the unified launcher script (.sh)
+set KOTLIN_CLI_WRAPPER_PATH=%~f0
+
+rem The '& call' after the app run is to avoid the "Terminate batch job (Y/N)?" prompt
+"%busybox_exe%" sh "%kotlin_cli_target_dir%\bin\launcher.sh" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem We use "%COMSPEC%" /d /c exit so that and/or operators work properly when calling kotlin.bat directly from a script
+@rem without the `call` command. With a plain `exit /B %ERRORLEVEL%`, using `kotlin.bat && echo success` would print
+@rem 'success' even in case of failure. Consumers would have to use `call kotlin.bat && echo success` for it to work.`
+"%COMSPEC%" /d /c exit %ERRORLEVEL%
diff --git a/gui/libs.versions.toml b/gui/libs.versions.toml
new file mode 100644
index 0000000..c10dff1
--- /dev/null
+++ b/gui/libs.versions.toml
@@ -0,0 +1,12 @@
+[versions]
+logback = "1.6.2"
+androidx-activity-compose = "1.13.0"
+androidx-lifecycle = "2.11.0"
+kotlinx-coroutines = "1.11.0"
+
+[libraries]
+logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
+androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
diff --git a/gui/project.yaml b/gui/project.yaml
new file mode 100644
index 0000000..cf44bc1
--- /dev/null
+++ b/gui/project.yaml
@@ -0,0 +1,4 @@
+modules:
+ - androidApp
+ - desktopApp
+ - shared
diff --git a/gui/shared/composeResources/drawable/compose-multiplatform.xml b/gui/shared/composeResources/drawable/compose-multiplatform.xml
new file mode 100644
index 0000000..1ffc948
--- /dev/null
+++ b/gui/shared/composeResources/drawable/compose-multiplatform.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gui/shared/module.yaml b/gui/shared/module.yaml
new file mode 100644
index 0000000..c86ba51
--- /dev/null
+++ b/gui/shared/module.yaml
@@ -0,0 +1,25 @@
+product:
+ type: kmp/lib
+ platforms: [ jvm, android ]
+
+dependencies:
+ - $compose.runtime
+ - $compose.foundation
+ - $compose.material3
+ - $compose.ui
+ - $compose.components.resources
+ - $compose.preview
+ - $compose.hotReload.runtimeApi
+ - $libs.androidx.lifecycle.viewmodel.compose
+ - $libs.androidx.lifecycle.runtime.compose
+
+dependencies@android:
+ - $compose.uiTooling
+
+settings:
+ compose:
+ enabled: true
+ resources:
+ packageName: com.hyperling.weather.resources
+ # Generate public accessors so other modules (and previews) can use Res.*
+ exposedAccessors: true
diff --git a/gui/shared/src/App.kt b/gui/shared/src/App.kt
new file mode 100644
index 0000000..beb6719
--- /dev/null
+++ b/gui/shared/src/App.kt
@@ -0,0 +1,51 @@
+package com.hyperling.weather
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.safeContentPadding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import org.jetbrains.compose.reload.DevelopmentEntryPoint
+import org.jetbrains.compose.resources.painterResource
+
+import com.hyperling.weather.resources.Res
+import com.hyperling.weather.resources.compose_multiplatform
+
+@Composable
+@Preview
+@DevelopmentEntryPoint
+fun App() {
+ MaterialTheme {
+ var showContent by remember { mutableStateOf(false) }
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .safeContentPadding()
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Button(onClick = { showContent = !showContent }) {
+ Text("Click me!")
+ }
+ AnimatedVisibility(showContent) {
+ val greeting = remember { Greeting().greet() }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(painterResource(Res.drawable.compose_multiplatform), null)
+ Text("Compose: $greeting")
+ }
+ }
+ }
+ }
+}
diff --git a/gui/shared/src/Greeting.kt b/gui/shared/src/Greeting.kt
new file mode 100644
index 0000000..3112a27
--- /dev/null
+++ b/gui/shared/src/Greeting.kt
@@ -0,0 +1,9 @@
+package com.hyperling.weather
+
+class Greeting {
+ private val platform = getPlatform()
+
+ fun greet(): String {
+ return "Hello, ${platform.name}!"
+ }
+}
diff --git a/gui/shared/src/Platform.kt b/gui/shared/src/Platform.kt
new file mode 100644
index 0000000..b7391d1
--- /dev/null
+++ b/gui/shared/src/Platform.kt
@@ -0,0 +1,7 @@
+package com.hyperling.weather
+
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform
diff --git a/gui/shared/src@android/Platform.android.kt b/gui/shared/src@android/Platform.android.kt
new file mode 100644
index 0000000..8180c4c
--- /dev/null
+++ b/gui/shared/src@android/Platform.android.kt
@@ -0,0 +1,9 @@
+package com.hyperling.weather
+
+import android.os.Build
+
+class AndroidPlatform : Platform {
+ override val name: String = "Android ${Build.VERSION.SDK_INT}"
+}
+
+actual fun getPlatform(): Platform = AndroidPlatform()
diff --git a/gui/shared/src@jvm/Platform.jvm.kt b/gui/shared/src@jvm/Platform.jvm.kt
new file mode 100644
index 0000000..5259e1c
--- /dev/null
+++ b/gui/shared/src@jvm/Platform.jvm.kt
@@ -0,0 +1,7 @@
+package com.hyperling.weather
+
+class JVMPlatform: Platform {
+ override val name: String = "Java ${System.getProperty("java.version")}"
+}
+
+actual fun getPlatform(): Platform = JVMPlatform()
diff --git a/gui/shared/test/PlatformTest.kt b/gui/shared/test/PlatformTest.kt
new file mode 100644
index 0000000..1749929
--- /dev/null
+++ b/gui/shared/test/PlatformTest.kt
@@ -0,0 +1,17 @@
+package com.hyperling.weather
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class PlatformTest {
+ @Test
+ fun `platform name is reported`() {
+ assertTrue(getPlatform().name.isNotBlank())
+ }
+
+ @Test
+ fun `greeting is built from the platform name`() {
+ assertEquals("Hello, ${getPlatform().name}!", Greeting().greet())
+ }
+}
diff --git a/gui/shared/test@android/PlatformAndroidTest.kt b/gui/shared/test@android/PlatformAndroidTest.kt
new file mode 100644
index 0000000..afe8025
--- /dev/null
+++ b/gui/shared/test@android/PlatformAndroidTest.kt
@@ -0,0 +1,11 @@
+package com.hyperling.weather
+
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class PlatformAndroidTest {
+ @Test
+ fun `platform name is reported`() {
+ assertTrue(getPlatform().name.isNotBlank())
+ }
+}
diff --git a/gui/shared/test@jvm/PlatformJvmTest.kt b/gui/shared/test@jvm/PlatformJvmTest.kt
new file mode 100644
index 0000000..db2ddff
--- /dev/null
+++ b/gui/shared/test@jvm/PlatformJvmTest.kt
@@ -0,0 +1,11 @@
+package com.hyperling.weather
+
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class PlatformJvmTest {
+ @Test
+ fun `platform name is reported`() {
+ assertTrue(getPlatform().name.isNotBlank())
+ }
+}