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()) + } +}