Compare commits

18 Commits
Author SHA1 Message Date
me 077de9d42f Agent's attempt to fix the latest build errors. 2026-11-03 19:35:21 -07:00
me 337403721b qwen3.6:35b's first attempt, does not compile (yet). 2026-11-02 15:28:46 -07:00
me eeda4a18c5 Add new Kotlin Multiplatform project to gui. 2026-10-30 07:02:43 -07:00
me ac88d08213 cli, up version, add Pahrump, change default days to 7 7 from 12 2. 2026-09-21 18:42:09 -07:00
me ccf6e69054 install, fix a few bugs, add aias for all names, and test the global source deletion. 2026-09-21 18:41:15 -07:00
me 50ae6a0f66 cli, add Best Friends in Kanab. 2026-09-21 10:23:11 -07:00
me 152758e03b cli, install, only run mkdir if the dir doesn't exist. 2026-09-14 11:26:40 -07:00
me 1bd33a768f cli, install, make the program compatible with Termux. 2026-09-14 11:19:32 -07:00
me 5601cd0f56 install, ensure node_modules exists, handle project being moved into subdirectory cli. 2026-09-12 15:39:18 -07:00
me cbca8ac5d2 prototype.js, refactor and add TODOs. 2026-09-12 15:23:00 -07:00
me 4bed4e5aff prototype,js, increment minor version since it is functional. 2026-09-12 14:08:20 -07:00
me 258d5e9e0a Fix directories being 1 too deep. 2026-09-12 14:07:14 -07:00
me acb4f38f71 Merge pull request 'Add Root Details' (#2) from dev into main
Reviewed-on: #2
2026-09-12 13:15:30 -07:00
me 41c80130d5 Reference that there is wiki info! 2026-09-12 13:12:45 -07:00
me 5bb9e51bfe Add planned directories with blank README's. 2026-09-12 13:09:23 -07:00
me 0ccd900219 Merge pull request 'prototype.js' (#1) from prod into main
Reviewed-on: #1
2026-09-12 13:07:29 -07:00
me f433cd87a8 Update the README for the main project with a planned directory structure 2026-09-12 13:06:06 -07:00
me 68aba793b9 Add a README for the CLI. 2026-09-12 12:58:04 -07:00
67 changed files with 5277 additions and 96 deletions

No files matched your search

+18 -2
View File
@@ -1,3 +1,19 @@
# template-mit # Nomadic Weather
Template with MIT license, protected `main` branch, and available `dev` branch. See the [wiki](https://git.hyperling.com/me/nomadic-weather/wiki) for more information.
## cli
Programs made while testing with the API endpoint.
## doc
Documentation.
## gui
Flutter project directed at Android and Linux.
## srv
Backend code.
+18
View File
@@ -0,0 +1,18 @@
# Nomadic Weather CLI
Prototype for playing with [Open-Meteo](https://open-meteo.com).
Locations need modified manually since it's still just a prototype.
## prototype.js
Got a little carried away and made it pretty formal.
Still a prototype though since the locations are hardcoded.
Would be made more complete by accepting a list of values, an env file,
or basically just any way of handling the locations as user input.
## install.sh
Allows the script to be installed either in ~/bin/ or /usr/local/bin/.
Also handles removal if it is no longer wanted.
Currently does not remove from /usr/local/src/.
+65 -29
View File
@@ -5,10 +5,12 @@
## Variables ## ## Variables ##
DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
PROJ="$(cd "$DIR"/.. &>/dev/null && pwd)"
PROG="$(basename -- "${BASH_SOURCE[0]}")" PROG="$(basename -- "${BASH_SOURCE[0]}")"
LOCAL=~/bin LOCAL=~/bin
GLOBAL=/usr/local/bin GLOBAL="/usr/local"
GLOBAL_SRC=/usr/local/src GLOBAL_BIN="$GLOBAL/bin"
GLOBAL_SRC="$GLOBAL/src"
name_local="prototype.js" name_local="prototype.js"
name_short="nomad" name_short="nomad"
@@ -19,88 +21,122 @@ name_abs="weather-list"
function usage { function usage {
cat <<- EOF cat <<- EOF
$PROG [-h] [-g] [a|l|s] [-r] $PROG [-h] [-g] [a: b|l|s] [-r]
Install $name_local to the system. Can be local, global, or removed. Install $name_local to the system. Can be local, global, or removed.
Parameters) Parameters)
h) Display this message. h) Display this message.
g) Install globally ($GLOBAL), otherwise local ($LOCAL). g) Install globally ($GLOBAL), otherwise local ($LOCAL).
a) Abstract name, unrelated to being nomadic. ($name_abs) a) All names / pseudonyms, rather than choosing from below.
l) Long name, the full name of the project. ($name_long) b) Basic name, unrelated to being nomadic. ($name_abs)
l) Long name, the full name of the project. ($name_long) [Default]
s) Short name, the full name of the project. ($name_short) s) Short name, the full name of the project. ($name_short)
r) Remove / uninstall the project according to the above flags. r) Remove / uninstall the project according to the above flags.
Examples) Examples)
- Install to the local user with the abstract name. - Install to the local user with the default name.
$PROG $PROG
- Install to all users with all names. - Install to all users with all names.
$PROG -alsg $PROG -ga
- Remove all instances. - Remove all instances.
$PROG -alsr && $PROG -alsrg $PROG -ra && $PROG -gra
Happy weather watching! :) Happy weather watching! :)
~ Hyperling ~ Hyperling
https://git.hyperling.com/me/nomadic-weather
EOF EOF
exit $1 exit $1
} }
## Validations ##
count=0
while [[ ! -d "$DIR/node_modules" ]]; do
if (( count > 0 )); then
echo "Failed to download libraries, please intervene."
exit 3
fi
if [[ -z "`which npm`" ]]; then
echo "Libraries are missing, please run 'npm install' first."
exit 2
else
echo "Downloading libraries with 'npm install'."
cd $DIR && npm install
fi
count=$(( count++ ))
done
## Parameters ## ## Parameters ##
while getopts ":gslarh" arg; do while getopts ":gslabrh" arg; do
case $arg in case $arg in
g) global="Y" ;; g) global="Y" ;;
s) short="Y" ;; s) short="Y" ;;
l) long="Y" ;; l) long="Y" ;;
a) abstract="Y" ;; b) abstract="Y" ;;
a) all="Y" ;;
r) remove="Y" ;; r) remove="Y" ;;
h) usage ;; h) usage ;;
*) echo "ERROR: Parameter $OPTARG was not recognized." && usage 1 ;; *) echo "ERROR: Parameter $OPTARG was not recognized." && usage 1 ;;
esac esac
done done
## Processing ##
name="" name=""
if [[ "$short" == "Y" ]]; then if [[ "$all" == "Y" || "$abstract" == "Y" ]]; then
name="$name $name_short"
fi
if [[ "$long" == "Y" ]]; then
name="$name $name_long"
fi
if [[ "$abstract" == "Y" || ( -z "$short" && -z "$long" ) ]]; then
name="$name $name_abs" name="$name $name_abs"
fi fi
if [[ "$all" == "Y" || "$short" == "Y" ]]; then
name="$name $name_short"
fi
if [[ "$all" == "Y" || "$long" == "Y" || -z "$name" ]]; then
name="$name $name_long"
fi
termux_check="/sdcard"
if [[ "$global" == "Y" && -e "$termux_check" ]]; then
echo "Detected $termux_check, assuming we are on Termux, global flag disabled."
global="N"
fi
## Processing ##
cmd="" cmd=""
dir="$LOCAL" dir="$LOCAL"
src="$DIR" src="$PROJ"
if [[ "$global" == "Y" ]]; then if [[ "$global" == "Y" ]]; then
dir="$GLOBAL"
cmd="sudo" cmd="sudo"
dir="$GLOBAL_BIN"
src="$GLOBAL_SRC/`basename "$PROJ"`"
echo "Requesting sudo privileges... " echo "Requesting sudo privileges... "
sudo echo "Success!" sudo echo "Success!"
mkdir -pv /usr/local/src
sudo cp -Rv "$DIR" $GLOBAL_SRC/
src="$GLOBAL_SRC/`basename "$DIR"`"
fi fi
if [[ -z "$remove" ]]; then if [[ -z "$remove" ]]; then
cmd="$cmd ln -svf $src/$name_local" cmd="$cmd ln -svf $src/cli/$name_local"
else else
cmd="$cmd rm -v" cmd="$cmd rm -v"
fi fi
## Main ## ## Main ##
# Untested # Global Source Code: Install / Removal
#if [[ "$global" == "Y" && "$remove" == "Y" ]]; then if [[ "$global" == "Y" ]]; then
# sudo rm -rfv "$src" if [[ "$remove" == "Y" ]]; then
#fi sudo rm -rfv "$src"
else
mkdir -pv "$GLOBAL_SRC"
sudo cp -Rv "$PROJ" "$GLOBAL_SRC"/
fi
fi
# Program: Install / Removal
for prog in $name; do for prog in $name; do
if [[ ! -d "$dir" ]]; then
mkdir -pv "$dir" mkdir -pv "$dir"
fi
$cmd "$dir/$prog" $cmd "$dir/$prog"
done done
+94 -65
View File
@@ -23,63 +23,48 @@
* - added args, basically took over the program without any incremental commits * - added args, basically took over the program without any incremental commits
* */ * */
/* TODO
* - Add way to manage a DB such as ~/.confg/nomadic-weather/locations.json
* - -c argument for creating locations
* - Should act like "read" to intake data for Season, Nickname, Lat, Long, and Elevation.
* - -d argument for deleting locations
* - Displays the list and asks which index# to delete.
* - -m argument for modifying locations
* - Displays a list and asks which index to open, then goes through create-like process.
* - Tie locations to seasons, as noted for the GUI in the Design, and sort on that rather than elevation.
* */
/* * * Libraries * * */
const yargs = require('yargs/yargs'); const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers'); const { hideBin } = require('yargs/helpers');
const version = '0.0.1'; /* * * Variables * * */
const version = '0.1.2';
const days_fore_max = 16; const days_fore_max = 16;
const days_fore_default = 12; const days_fore_default = 7;
const days_past_default = 2; const days_past_default = 7;
const ft_to_m = 0.3048; const ft_to_m = 0.3048;
const argv = yargs(hideBin(process.argv)) const RED = "\x1b[31m";
.option('days_fore', { const GRN = "\x1b[32m";
alias: 'f', const YLW = "\x1b[33m";
describe: 'Set forecast days, such as "-f 10". Maximum of 16.', const BLU = "\x1b[34m";
type: 'number', const MGN = "\x1b[35m";
requiresArg: true, const CYN = "\x1b[36m";
}) const WHT = "\x1b[37m";
.option('days_past', { const RESET = "\x1b[0m";
alias: 'p',
describe: 'Set past days, such as "-p 3"',
type: 'number',
requiresArg: true,
})
.option('calc_elevation', {
alias: 'e',
describe: 'Set to calculate elevation at lat/long.',
type: 'boolean',
})
.check((argv) => {
if (argv.f !== undefined && (isNaN(argv.f) || argv.f < 0)) {
throw new Error('Forecast days must be a non-negative number');
}
if (argv.p !== undefined && (isNaN(argv.p) || argv.p < 0)) {
throw new Error('Past days must be a non-negative number');
}
return true;
})
//.demandOption(['timeout', 'debug']) // Optional: require these arguments
.strict()
.help()
.version(version)
.argv
;
function max (value, upper) { const pref_high_upper = 75;
return (value > upper) ? upper : value; const pref_high_lower = 60;
} const pref_low_upper = 55;
const days_fore = max(argv.hasOwnProperty("days_fore") ? argv.days_fore : days_fore_default, days_fore_max); const pref_low_lower = 40;
const days_past = argv.hasOwnProperty("days_past") ? argv.days_past : days_past_default;
const calc_elevation = argv.calc_elevation ? true : false;
const days = days_fore + days_past; const tolerance_light = 10;
const tolerance_heavy = 20;
console.log(`Averaging for ${days} days: ${days_fore} forecasted, ${days_past} past.`);
if (calc_elevation) console.log("Calculating elevation rather than using provided values.");
else console.log("Using user-provided elevations when they exist.");
const locations = [ const locations = [
//{ name: "Flagstaff, AZ", lat: 35.1983, lon: -111.6513, elevation: 6903 }, //{ name: "Flagstaff, AZ", lat: 35.1983, lon: -111.6513, elevation: 6903 },
@@ -105,30 +90,23 @@ const locations = [
//{ name: "Ehrenberg, AZ", lat: 33.9897, lon: -114.3550, elevation: 333 }, //{ name: "Ehrenberg, AZ", lat: 33.9897, lon: -114.3550, elevation: 333 },
{ name: "Ehrenberg: ++", lat: 33.9897, lon: -114.3550, elevation: 300 }, { name: "Ehrenberg: ++", lat: 33.9897, lon: -114.3550, elevation: 300 },
{ name: "Quartzsite: ++", lat: 33.6763, lon: -114.2104, elevation: 900 }, { name: "Quartzsite: ++", lat: 33.6763, lon: -114.2104, elevation: 900 },
{ name: "Kanab: BF", lat: 37.1412, lon: -112.5439, elevation: 5500 },
//{ name: "Kanab: ++", lat: 37.1399, lon: -112.5926, elevation: 5700 },
{ name: "Pahrump, NV", lat: 36.1925, lon: -115.9758, elevation: 2700 },
]; ];
locations.sort((a, b) => a.elevation - b.elevation); locations.sort((a, b) => a.elevation - b.elevation);
const RED = "\x1b[31m"; /* * * Functions * * */
const GRN = "\x1b[32m";
const YLW = "\x1b[33m";
const BLU = "\x1b[34m";
const MGN = "\x1b[35m";
const CYN = "\x1b[36m";
const WHT = "\x1b[37m";
const RESET = "\x1b[0m";
function highlight(value, min, max) { function highlight(value, min, max) {
const n = parseFloat(value); const n = parseFloat(value);
return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value; return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value;
} }
const pref_high_upper = 75; function pad(str, width) {
const pref_high_lower = 60; const visible = str.replace(/\x1b\[\d+m/g, '').length;
const pref_low_upper = 55; return ' '.repeat(Math.max(0, width - visible)) + str;
const pref_low_lower = 40; }
const tolerance_light = 10;
const tolerance_heavy = 20;
function check (value, min, max) { function check (value, min, max) {
const n = parseFloat(value); const n = parseFloat(value);
@@ -156,11 +134,60 @@ function check (value, min, max) {
//return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value; //return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value;
} }
function pad(str, width) { function max (value, upper) {
const visible = str.replace(/\x1b\[\d+m/g, '').length; return (value > upper) ? upper : value;
return ' '.repeat(Math.max(0, width - visible)) + str;
} }
/* * * Parameters * * */
const argv = yargs(hideBin(process.argv))
.option('days_fore', {
alias: 'f',
describe: 'Future / forecast days, such as "-f 10". Maximum of 16.',
type: 'number',
requiresArg: true,
})
.option('days_past', {
alias: 'p',
describe: 'Past days, such as "-p 3"',
type: 'number',
requiresArg: true,
})
.option('calc_elevation', {
alias: 'e',
describe: 'Set to calculate elevation at lat/long rather than use supplied.',
type: 'boolean',
})
.check((argv) => {
if (argv.f !== undefined && (isNaN(argv.f) || argv.f < 0)) {
throw new Error('Forecast days must be a non-negative number');
}
if (argv.p !== undefined && (isNaN(argv.p) || argv.p < 0)) {
throw new Error('Past days must be a non-negative number');
}
return true;
})
//.demandOption(['timeout', 'debug']) // Optional: require these arguments
.strict()
.help()
.version(version)
.argv
;
/* * * Processing * * */
const days_fore = max(argv.hasOwnProperty("days_fore") ? argv.days_fore : days_fore_default, days_fore_max);
const days_past = argv.hasOwnProperty("days_past") ? argv.days_past : days_past_default;
const calc_elevation = argv.calc_elevation ? true : false;
const days = days_fore + days_past;
console.log(`Averaging for ${days} days: ${days_fore} forecasted, ${days_past} past.`);
if (calc_elevation) console.log("Calculating elevation rather than using provided values.");
else console.log("Using user-provided elevations when they exist.");
/* * * Main * * */
async function getForecast(loc) { async function getForecast(loc) {
const url = new URL("https://api.open-meteo.com/v1/forecast"); const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", loc.lat); url.searchParams.set("latitude", loc.lat);
@@ -213,3 +240,5 @@ async function getForecast(loc) {
} }
} }
})(); })();
/* * * Finish * * */
View File
Whitespace-only changes.
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
Nomadic Weather
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="KotlinToolchainAndroidProjectSystem" />
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AmperProjectManager">
<option name="amperProject" value="true" />
<option name="projectRoot" value="$PROJECT_DIR$" />
</component>
</project>
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/NomadicWeather.iml" filepath="$PROJECT_DIR$/.idea/NomadicWeather.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+22
View File
@@ -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@<platform>` 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 <module>` 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/).
+13
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">NomadicWeather</string>
</resources>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+24
View File
@@ -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()
}
+13
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
+10
View File
@@ -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()
}
}
Executable
+292
View File
@@ -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 <<EOF
Welcome to
@@@ @@@@ @@@ @@@
@@@ @@@@ @@@ @@@ @@@
@@@ #@@@" ,@@@ @@@
@@@ ,@@@% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@,
@@@ @@@@ @@@@@%"%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%@@@@@
@@@@@@% @@@% %@@@ @@@ @@@ @@@ @@@@ %@@%
@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@
@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@
@@@ *@@@% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@
@@@ %@@@= %@@@@*,*@@@@% @@@@### @@@ @@@ @@@ @@@
@@@ @@@@ "@@@@@@@" %@@@@@ @@@ @@@ @@@ @@@
EOF
echo
echo "This is the first run of the Kotlin CLI v$kotlin_cli_version, so we need to download the Kotlin Toolchain."
echo "Please give us a few seconds, subsequent runs will be faster."
echo
fi
echo "Downloading $moniker..."
rm -f "$temp_file"
if command -v curl >/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" "$@"
+280
View File
@@ -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%
+19
View File
@@ -0,0 +1,19 @@
[versions]
logback = "1.6.2"
androidx-activity-compose = "1.13.0"
androidx-lifecycle = "2.11.0"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.9.0"
ktor = "3.0.1"
[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" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+4
View File
@@ -0,0 +1,4 @@
modules:
- androidApp
- desktopApp
- shared
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
+31
View File
@@ -0,0 +1,31 @@
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
- $libs.ktor.client.core
- $libs.kotlinx.serialization.json
dependencies@jvm:
- $libs.ktor.client.cio
dependencies@android:
- $libs.ktor.client.okhttp
- $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
+61
View File
@@ -0,0 +1,61 @@
package com.hyperling.weather
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@Composable
fun App() {
// Initialize platform-specific storage and environment (JVM/Desktop vs Android)
val platformInit = remember { getPlatformInit() }
LaunchedEffect(platformInit) {
platformInit.initialize()
}
MaterialTheme {
MainScreen(modifier = Modifier.fillMaxSize())
}
}
@Composable
fun MainScreen(modifier: Modifier = Modifier) {
val viewModel = remember { MainViewModel() }
// Initialize ViewModel by loading existing locations
LaunchedEffect(Unit) {
viewModel.init()
}
Box(modifier = modifier) {
CelticWheel(
viewModel = viewModel,
onLocationClick = { loc ->
viewModel.startEditing(loc)
},
sizeInDp = 360
)
// If editing a location, show the Editor overlay
if (viewModel.editingLocation != null) {
LocationEditor(
entry = viewModel.editingLocation,
onDismiss = { viewModel.cancelEditing() },
onSave = { loc ->
viewModel.saveLocation(loc)
viewModel.cancelEditing()
viewModel.fetchAllWeather() // Refresh data after saving
}
)
}
// Loading indicator (optional visual cue)
if (viewModel.loading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
color = SeasonColors.Accent
)
}
}
}
+437
View File
@@ -0,0 +1,437 @@
package com.hyperling.weather
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import kotlin.math.cos
import kotlin.math.sin
/**
* The Celtic Season Wheel - 8 pie wedges representing the year's seasons.
* Each wedge shows weather data for a saved location.
*/
@Composable
fun CelticWheel(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
onLocationClick: (LocationEntry?) -> Unit,
onWeatherRefresh: () -> Unit = {},
sizeInDp: Int = 360,
) {
var centerX by remember { mutableStateOf(0f) }
var centerY by remember { mutableStateOf(0f) }
var radius by remember { mutableStateOf(0f) }
val wheelSizeDp = sizeInDp.dp
val hasData = viewModel.weatherData.isNotEmpty() && !viewModel.loading
Box(
modifier = modifier
.size(wheelSizeDp)
.onSizeChanged { size ->
centerX = size.width / 2f
centerY = size.height / 2f
radius = (size.minDimension / 2f) * 0.85f
}
) {
// Background circle
Box(
modifier = Modifier
.fillMaxSize()
.background(SeasonColors.Background.copy(alpha = 0.2f), CircleShape)
)
// Draw the wedges via canvas
CelticWheelCanvas(
centerX = centerX,
centerY = centerY,
radius = radius,
viewModel = viewModel,
)
// Hand pointer - positioned by current season angle
val handInfo = viewModel.getHandInfo()
Box(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp)
) {
HandPointer(angleDeg = Math.toDegrees(handInfo.angle).toFloat())
}
// Center info circle
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.align(Alignment.Center)
.size(72.dp)
.background(SeasonColors.CenterBg, CircleShape)
.clickable { if (hasData) onWeatherRefresh() }
) {
Text(
text = if (hasData) "Update" else "Add",
fontSize = 9.sp,
color = SeasonColors.TextSecondary
)
Text(
text = if (hasData) "Now" else "Locs",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = SeasonColors.TextPrimary
)
}
// Empty slot buttons around the wheel edge
for (i in viewModel.locations.size until Season.Segments) {
val angle = Season.angleFor(i)
val buttonX = centerX + radius * cos(angle) - 16
val buttonY = centerY + radius * sin(angle) - 16
IconButton(
x = buttonX.toInt(),
y = buttonY.toInt(),
sizeDp = 32,
icon = Icons.Default.Add,
tint = SeasonColors.Accent.copy(alpha = 0.5f),
onClick = { onLocationClick(null) }
)
}
// Weather data overlays for each filled slot
viewModel.locations.forEachIndexed { idx, location ->
val weather = viewModel.weatherData[location.id]
if (weather == null) return@forEachIndexed
val angle = Season.angleFor(idx)
val labelRadius = radius * 0.42f
val labelX = centerX + labelRadius * cos(angle) - 35
val labelY = centerY + labelRadius * sin(angle) - 12
// Temperature display
val highStr = weather.todayHighFahrenheit?.toInt()?.toString() ?: "?"
val lowStr = weather.todayLowFahrenheit?.toInt()?.toString() ?: "?"
LocationTempCard(
x = labelX.toInt(),
y = labelY.toInt(),
high = highStr,
low = lowStr,
seasonColor = Season.All[idx].color,
onDelete = { viewModel.deleteLocation(location.id) },
)
// Location name at outer ring
val nameRadius = radius * 0.68f
val nameAngle = Season.angleFor(idx)
val nameX = centerX + nameRadius * cos(nameAngle) - 35
val nameY = centerY + nameRadius * sin(angle) - 10
LocationLabel(
x = nameX.toInt(),
y = nameY.toInt(),
text = location.nickname,
)
}
}
}
/**
* Canvas-based wedge drawing for the wheel.
*/
@Composable
private fun CelticWheelCanvas(
centerX: Float,
centerY: Float,
radius: Float,
viewModel: MainViewModel,
) {
val sweepAngle = 360f / Season.Segments
androidx.compose.foundation.Canvas(
modifier = Modifier.fillMaxSize()
) {
val currentRadius = (size.minDimension / 2f) * 0.85f
val center = Offset(centerX, centerY)
// Draw wedges
for (i in Season.All.indices) {
val season = Season.All[i]
val hasData = i < viewModel.locations.size
&& viewModel.weatherData[viewModel.locations[i].id] != null
drawWedge(
center = center,
radius = currentRadius,
startAngleDeg = -90f + i * sweepAngle,
sweepDeg = sweepAngle,
color = if (hasData) season.color.copy(alpha = 0.6f)
else SeasonColors.WedgeEmpty.copy(alpha = 0.1f),
style = Fill
)
// Separator line between wedges
val sepAngle = -90f + (i + 1) * sweepAngle
val sepRad = Math.toRadians(sepAngle.toDouble())
drawLine(
color = SeasonColors.Divider.copy(alpha = if (hasData) 0.5f else 0.2f),
start = Offset(
centerX + 38f * cos(sepRad.toFloat()),
centerY + 38f * sin(sepRad.toFloat())
),
end = Offset(
centerX + (currentRadius - 2f) * cos(sepRad.toFloat()),
centerY + (currentRadius - 2f) * sin(sepRad.toFloat())
),
strokeWidth = 1.5f
)
}
// Inner ring
drawCircle(
color = SeasonColors.Divider.copy(alpha = 0.3f),
radius = currentRadius * 0.85f,
center = center,
style = Stroke(width = 1f)
)
// Outer ring
drawCircle(
color = SeasonColors.Divider.copy(alpha = 0.4f),
radius = currentRadius - 2f,
center = center,
style = Stroke(width = 2f)
)
// Center circle (behind text column)
drawCircle(
color = SeasonColors.CenterBg,
radius = 36f,
center = center
)
}
}
/**
* Draw a wedge path on canvas.
*/
private fun DrawScope.drawWedge(
center: Offset,
radius: Float,
startAngleDeg: Float,
sweepDeg: Float,
color: Color,
style: androidx.compose.ui.graphics.drawscope.DrawStyle,
) {
if (sweepDeg <= 0f || radius <= 0f) return
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(center.x, center.y)
// First point on arc (start of wedge)
val startRad = Math.toRadians(startAngleDeg.toDouble())
val startX = center.x + radius * cos(startRad.toFloat()).toFloat()
val startY = center.y + radius * sin(startRad.toFloat()).toFloat()
lineTo(startX, startY)
// Arc sweep to end point
arcTo(
androidx.compose.ui.geometry.Rect(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
),
startAngleDeg, sweepDeg, true
)
close()
}
drawPath(path, color, style)
}
/**
* Small icon button at absolute position.
*/
@Composable
private fun IconButton(
x: Int,
y: Int,
sizeDp: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
tint: Color = SeasonColors.TextPrimary,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.offset(x.dp, y.dp)
.size(sizeDp.dp)
.background(Color.Transparent, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(
icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(sizeDp * 0.6f)
)
}
}
/**
* Hand pointer showing current season position.
*/
@Composable
private fun HandPointer(
angleDeg: Float,
sizeDp: Int = 24,
) {
androidx.compose.foundation.Canvas(
modifier = Modifier.size(sizeDp.dp)
) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = size.width / 2f
val angleRad = Math.toRadians(angleDeg.toDouble())
// Pointer tip (points outward, away from center)
val tipX = cx + (r - 3) * cos(angleRad).toFloat()
val tipY = cy + (r - 3) * sin(angleRad).toFloat()
// Base points on circle
val baseAngle1 = angleRad + Math.PI / 7
val baseAngle2 = angleRad - Math.PI / 7
val base1X = cx + (r - 8) * cos(baseAngle1).toFloat()
val base1Y = cy + (r - 8) * sin(baseAngle1).toFloat()
val base2X = cx + (r - 8) * cos(baseAngle2).toFloat()
val base2Y = cy + (r - 8) * sin(baseAngle2).toFloat()
androidx.compose.ui.graphics.Path().apply {
moveTo(tipX, tipY)
lineTo(base1X, base1Y)
lineTo(base2X, base2Y)
close()
}.let { path ->
drawPath(path, color = Color.White.copy(alpha = 0.85f))
}
// Pivot dot
drawCircle(
color = SeasonColors.Accent,
radius = 4f,
center = Offset(cx, cy)
)
}
}
/**
* Temperature display card for a location on the wheel.
*/
@Composable
private fun LocationTempCard(
x: Int,
y: Int,
high: String,
low: String,
seasonColor: Color,
onDelete: () -> Unit,
) {
Box(
modifier = Modifier
.offset(x.dp, y.dp)
.size(72.dp, 28.dp)
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(4.dp))
.clickable(onClick = onDelete)
) {
// Color bar on left edge
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.width(3.dp)
.background(seasonColor.copy(alpha = 0.7f))
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).padding(horizontal = 4.dp)
) {
Row {
Text(high, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f))
Text("/", fontSize = 10.sp, color = SeasonColors.TextSecondary)
Text(low, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f))
}
Text(
text = "°F",
fontSize = 8.sp,
color = SeasonColors.TextSecondary
)
}
// Delete button (top right)
Box(
modifier = Modifier
.offset(52.dp, -2.dp)
.size(14.dp)
.background(Color.Red.copy(alpha = 0.3f), CircleShape)
.clickable(onClick = onDelete),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(10.dp)
)
}
}
}
/**
* Location name label at outer edge of wheel.
*/
@Composable
private fun LocationLabel(
x: Int,
y: Int,
text: String,
) {
Text(
text = text,
fontSize = 10.sp,
color = Color.White.copy(alpha = 0.85f),
fontWeight = FontWeight.Medium,
modifier = Modifier.offset(x.dp, y.dp)
)
}
+9
View File
@@ -0,0 +1,9 @@
package com.hyperling.weather
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
+140
View File
@@ -0,0 +1,140 @@
package com.hyperling.weather
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Dialog component for adding or editing a location.
*/
@Composable
fun LocationEditor(
entry: LocationEntry?,
onDismiss: () -> Unit,
onSave: (LocationEntry) -> Unit,
) {
var zip by androidx.compose.runtime.mutableStateOf(entry?.zipCode ?: "")
var lat by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.latitude != 0.0) entry.latitude.toString() else "")
var lon by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.longitude != 0.0) entry.longitude.toString() else "")
val isEdit = entry != null
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth(0.8f)
.background(Color.White, RoundedCornerShape(16.dp))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (isEdit) "Edit Location" else "Add Location",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = SeasonColors.TextPrimary
)
Spacer(modifier = Modifier.size(16.dp))
// Nickname / Zip Code input
TextField(
value = zip,
onValueChange = { zip = it },
label = { Text("Zip Code or Place Name") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("e.g. 90210 or London") }
)
Spacer(modifier = Modifier.size(8.dp))
// Latitude / Longitude (Advanced)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
TextField(
value = lat,
onValueChange = { lat = it },
label = { Text("Latitude") },
modifier = Modifier.weight(1f)
)
TextField(
value = lon,
onValueChange = { lon = it },
label = { Text("Longitude") },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.size(24.dp))
// Actions
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxWidth()
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Cancel, contentDescription = "Cancel", tint = SeasonColors.TextSecondary)
}
androidx.compose.material3.Button(
onClick = {
// Parse inputs to create/update LocationEntry
val latParsed = lat.toDoubleOrNull() ?: 0.0
val lonParsed = lon.toDoubleOrNull() ?: 0.0
onSave(
LocationEntry(
id = entry?.id ?: System.currentTimeMillis(),
zipCode = zip,
latitude = latParsed,
longitude = lonParsed,
nickname = zip, // Default nickname is the input string
lastUpdated = entry?.lastUpdated ?: 0L
)
)
},
enabled = zip.isNotBlank() || lat != "0.0" || lon != "0.0"
) {
Icon(Icons.Default.Check, contentDescription = "Save")
androidx.compose.material3.Text("Save", modifier = Modifier.padding(start = 4.dp))
}
}
}
// Backdrop / Overlay to dismiss
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() }
) {}
}
}
+125
View File
@@ -0,0 +1,125 @@
package com.hyperling.weather
/**
* A saved location for weather lookup.
* Either zipCode OR (latitude + longitude) must be provided.
*/
data class LocationEntry(
val id: Long = System.currentTimeMillis(),
var zipCode: String = "",
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var nickname: String = "",
val lastUpdated: Long = 0L
) {
/**
* Whether this location has valid data to fetch weather for.
*/
val isValid: Boolean
get() = zipCode.isNotBlank() || (latitude != 0.0 && longitude != 0.0)
/**
* Display label for this location.
*/
val displayLabel: String
get() = nickname.takeIf { it.isNotBlank() }
?: zipCode.takeIf { it.isNotBlank() }
?: "Lat ${latitude.toString().take(5)}, Lon ${longitude.toString().take(5)}"
/**
* The coordinates to use for weather API lookup.
*/
fun getCoordinates(): Pair<Double, Double>? {
return if (zipCode.isNotBlank()) null else {
if (latitude != 0.0 && longitude != 0.0) Pair(latitude, longitude) else null
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LocationEntry) return false
// Locations are equal if they share the same zip or coordinates
if (zipCode.isNotBlank() && other.zipCode.isNotBlank()) {
return zipCode.equals(other.zipCode, ignoreCase = true)
}
if (latitude != 0.0 || longitude != 0.0) {
return latitude == other.latitude && longitude == other.longitude
}
return id == other.id
}
override fun hashCode(): Int {
return when {
zipCode.isNotBlank() -> zipCode.lowercase().hashCode()
latitude != 0.0 || longitude != 0.0 -> latitude.hashCode() * 31 + longitude.hashCode()
else -> id.hashCode()
}
}
}
/**
* Weather data returned from OpenMeteo for a single day.
*/
data class DailyWeather(
val date: String,
val tempMaxCelsius: Double?,
val tempMinCelsius: Double?,
) {
val tempMaxFahrenheit: Double?
get() = tempMaxCelsius?.let { celsiusToFahrenheit(it) }
val tempMinFahrenheit: Double?
get() = tempMinCelsius?.let { celsiusToFahrenheit(it) }
companion object {
fun celsiusToFahrenheit(celsius: Double): Double {
return celsius * 9.0 / 5.0 + 32.0
}
}
}
/**
* Aggregated weather data for a location.
*/
data class LocationWeather(
val locationId: Long,
val zipCode: String,
val nickname: String,
val latitude: Double,
val longitude: Double,
val dailyWeather: List<DailyWeather>,
val lastUpdatedMillis: Long,
// Convenience properties for the "today" data (first in list)
val todayHighCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMaxCelsius,
val todayLowCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMinCelsius,
val todayHighFahrenheit: Double? get() = todayHighCelsius?.let { DailyWeather.celsiusToFahrenheit(it) },
val todayLowFahrenheit: Double? get() = todayLowCelsius?.let { DailyWeather.celsiusToFahrenheit(it) },
)
/**
* OpenMeteo API response for the daily forecast endpoint.
*/
data class OpenMeteoResponse(
val latitude: List<Double>,
val longitude: List<Any>, // HRES, etc. - we don't need this
val generationtime_ms: Double,
val utc_offset_seconds: Int,
val timezone: String,
val timezone_abbreviation: String,
val elevation: Double,
val daily: DailyResponse
)
data class DailyResponse(
val time: List<String>,
val weathercode: List<Int>?,
val temperature_2m_max: List<Double>?,
val temperature_2m_min: List<Double>?,
val apparent_temperature_max: List<Double>?,
val apparent_temperature_min: List<Double>?,
val sunrise: List<String>?,
val sunset: List<String>?,
val precipitation_sum: List<Double>?,
val wind_speed_10m_max: List<Double>?,
val shortwave_radiation_sum: List<Double>?,
)
+46
View File
@@ -0,0 +1,46 @@
package com.hyperling.weather
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* JSON serializer helper for location data.
*/
object LocationSerializer {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
@Serializable
private data class StoredLocation(
val id: Long,
val zipCode: String,
val latitude: Double,
val longitude: Double,
val nickname: String,
val lastUpdated: Long
)
fun locationsToJson(locations: List<LocationEntry>): String {
return json.encodeToString(
kotlinx.serialization.listSerializer(StoredLocation.serializer()),
locations.map { locationToStored(it) }
)
}
fun jsonToLocations(jsonStr: String): List<LocationEntry> {
return try {
json.decodeFromString<List<StoredLocation>>(jsonStr).map { storedToLocation(it) }
} catch (e: Exception) {
emptyList()
}
}
private fun locationToStored(loc: LocationEntry): StoredLocation =
StoredLocation(loc.id, loc.zipCode, loc.latitude, loc.longitude, loc.nickname, loc.lastUpdated)
private fun storedToLocation(stored: StoredLocation): LocationEntry =
LocationEntry(stored.id, stored.zipCode, stored.latitude, stored.longitude, stored.nickname, stored.lastUpdated)
}
+37
View File
@@ -0,0 +1,37 @@
package com.hyperling.weather
/**
* Platform-agnostic location storage abstraction.
*/
object LocationStorage {
/**
* Load all saved locations.
*/
fun load(): List<LocationEntry> = getStorage().load()
/**
* Save all locations (replaces existing).
*/
fun save(locations: List<LocationEntry>) = getStorage().save(locations)
/**
* Save a single location, merging with existing list.
*/
fun saveOne(location: LocationEntry) = getStorage().saveOne(location)
/**
* Delete a location by id.
*/
fun delete(id: Long) = getStorage().delete(id)
expect fun getStorage(): StorageBackend
}
interface StorageBackend {
fun load(): List<LocationEntry>
fun save(locations: List<LocationEntry>)
fun saveOne(location: LocationEntry)
fun delete(id: Long)
}
expect fun getPlatformStorage(): StorageBackend
+195
View File
@@ -0,0 +1,195 @@
package com.hyperling.weather
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* ViewModel for the main screen. Manages locations, weather data, and UI state.
*/
class MainViewModel : ViewModel() {
var loading by mutableStateOf(false)
private set
var error: String? by mutableStateOf(null)
private set
// Up to 8 locations in wheel order
var locations by mutableStateOf<List<LocationEntry>>(emptyList())
private set
// Weather data keyed by location id
val weatherData = mutableStateMapOf<Long, LocationWeather>()
// Currently editing location (null means create new)
var editingLocation: LocationEntry? by mutableStateOf(null)
private set
fun initialize() {
viewModelScope.launch {
locations = LocationStorage.load()
if (locations.isNotEmpty()) {
fetchAllWeather()
}
}
}
/**
* Add or update a location entry.
*/
fun saveLocation(location: LocationEntry) {
val validated = validateLocation(location)
if (!validated.second) {
error = validated.first
return
}
viewModelScope.launch {
LocationStorage.saveOne(validated.third)
locations = LocationStorage.load()
// Immediately fetch weather for new/updated location
fetchWeatherFor(validated.third)
}
}
/**
* Delete a location.
*/
fun deleteLocation(id: Long) {
viewModelScope.launch {
LocationStorage.delete(id)
locations = LocationStorage.load()
weatherData.remove(id)
}
}
/**
* Start editing a location (for the dialog).
*/
fun startEditing(location: LocationEntry?) {
editingLocation = location?.copy() ?: LocationEntry(
id = System.currentTimeMillis(),
nickname = ""
)
}
/**
* Clear the current edit session.
*/
fun cancelEditing() {
editingLocation = null
}
/**
* Fetch weather for all saved locations.
*/
suspend fun fetchAllWeather() {
loading = true
error = null
try {
WeatherRepository.init(getEngine())
val results = withContext(Dispatchers.IO) {
locations.map { loc ->
loc to WeatherRepository.getWeatherForLocation(loc)
}
}
for ((loc, result) in results) {
if (result.isSuccess) {
weatherData[loc.id] = result.getOrThrow()
} else {
error = "Failed to fetch weather for ${loc.displayLabel}: ${result.exceptionOrNull()?.message}"
}
}
} catch (e: Exception) {
error = "Network error: ${e.message}"
} finally {
loading = false
}
}
/**
* Fetch weather for a single location.
*/
private suspend fun fetchWeatherFor(location: LocationEntry) {
WeatherRepository.init(getEngine())
val result = withContext(Dispatchers.IO) {
WeatherRepository.getWeatherForLocation(location)
}
if (result.isSuccess) {
weatherData[location.id] = result.getOrThrow()
} else {
error = "Failed to fetch weather: ${result.exceptionOrNull()?.message}"
}
}
/**
* Validate a location entry. Returns (errorMessage?, validatedLocation).
*/
private fun validateLocation(input: LocationEntry): Triple<String?, LocationEntry, Boolean> {
val hasZip = input.zipCode.isNotBlank()
val hasCoords = input.latitude != 0.0 && input.longitude != 0.0
if (!hasZip && !hasCoords) {
return Triple("Either a zip code or latitude/longitude is required.", input, false)
}
// If only zip provided, validate it's numeric
if (hasZip && !hasCoords) {
val cleanZip = input.zipCode.trim().replace(NonDigitRegex, "")
if (cleanZip.length != 5) {
return Triple("Please enter a valid zip code.", input.copy(zipCode = cleanZip), false)
}
}
// If coords provided but no zip, generate a placeholder
val validated = if (hasCoords && !hasZip) {
input.copy(zipCode = "")
} else {
input.copy(zipCode = input.zipCode.trim())
}
return Triple(null, validated, true)
}
/**
* Get weather data for a specific location wedge.
*/
fun getWeatherForLocation(idx: Int): LocationWeather? {
return if (idx < locations.size) {
weatherData[locations[idx].id]
} else null
}
/**
* Get the hand info for the current date/season position.
*/
fun getHandInfo(): HandInfo {
val calendar = java.util.Calendar.getInstance()
return HandInfo(
dayOfYear = calendar.get(java.util.Calendar.DAY_OF_YEAR),
totalDaysInYear = if (calendar.isLeapYear(calendar.get(java.util.Calendar.YEAR))) 366 else 365,
fractionThroughSeason = Season.handAngle(),
currentSeason = Season.current()
)
}
companion object {
private val NonDigitRegex = "[^0-9]".toRegex()
}
}
// Platform-specific engine provider
internal expect fun getEngine(): io.ktor.client.engine.HttpClientEngine
+7
View File
@@ -0,0 +1,7 @@
package com.hyperling.weather
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
+11
View File
@@ -0,0 +1,11 @@
package com.hyperling.weather
/**
* Platform-specific initialization interface.
* Used to set up storage backends or other environment needs based on the platform.
*/
interface PlatformInit {
fun initialize()
}
expect fun getPlatformInit(): PlatformInit
+166
View File
@@ -0,0 +1,166 @@
package com.hyperling.weather
import androidx.compose.ui.graphics.Color
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* The 8 positions on the Celtic Season Wheel.
* Based on solar events arranged clockwise starting from Summer at top (12 o'clock).
*/
enum class Season(
val label: String,
val fullName: String,
val color: Color,
/**
* Start of season expressed as month (1-12) and day (1-31).
* Used for the hand pointer position.
*/
val startMonth: Int,
val startDay: Int,
) {
// Summer solstice area - top of wheel (12 o'clock = 0 degrees in Compose arc coords)
SUMMER1("V1", "Summer Cross Quarter to Fall Equinox", Color(0xFFFBBC04), 6, 21),
SUMMER2("V2", "Fall Equinox to Winter Solstice (early)", Color(0xFFFF8F00), 8, 1),
FALL1("F1", "Fall Equinox to Fall Cross Quarter", Color(0xFFE65100), 9, 21),
FALL2("F2", "Winter Solstice to Winter Cross Quarter (early)", Color(0xFF6D4C41), 11, 1),
WINTER1("W1", "Winter Cross Quarter to Spring Equinox (late)", Color(0xFF546E7A), 12, 21),
WINTER2("W2", "Spring Equinox to Summer Solstice (early)", Color(0xFF37474F), 2, 1),
SPRING1("S1", "Summer Cross Quarter to Fall Equinox", Color(0xFF4CAF50), 3, 21),
SPRING2("S2", "Cross Quarter to Summer Solstice", Color(0xFF81C784), 5, 1);
companion object {
val All = values()
val Segments = All.size // 8
/**
* Get the current season based on today's date.
*/
fun current(): Season {
val calendar = java.util.Calendar.getInstance()
val month = calendar.get(java.util.Calendar.MONTH) + 1 // 1-12
val day = calendar.get(java.util.Calendar.DAY_OF_MONTH)
return All.find { season ->
val startStart = season.startMonth * 100 + season.startDay
// Calculate end as next season's start
val idx = season.ordinal
val nextSeason = if (idx < All.size - 1) All[idx + 1] else All[0]
val endStart = nextSeason.startMonth * 100 + nextSeason.startDay
val currentPos = month * 100 + day
when {
startStart <= endStart ->
currentPos in startStart..endStart
else ->
currentPos >= startStart || currentPos <= endStart
}
} ?: SPRING1
}
/**
* Get the hand angle (in radians) for the current date.
* 0 radians = top of wheel (Summer Solstice), increasing clockwise.
*/
fun handAngle(): Double {
val calendar = java.util.Calendar.getInstance()
val month = calendar.get(java.util.Calendar.MONTH) + 1
val day = calendar.get(java.util.Calendar.DAY_OF_MONTH)
// Calculate which season we're in and how far through it
val currentSeason = current()
val idx = currentSeason.ordinal
val nextIdx = (idx + 1) % All.size
val nextSeason = All[nextIdx]
val startPos = currentSeason.startMonth * 100 + currentSeason.startDay
val endPos = nextSeason.startMonth * 100 + nextSeason.startDay
// Handle year wrap for seasons that cross January
var totalDays = if (endPos > startPos) {
daysBetween(currentSeason.startMonth, currentSeason.startDay,
nextSeason.startMonth, nextSeason.startDay)
} else {
// Crosses year boundary
val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, currentSeason.startDay)
val daysRemaining = daysInCurrentMonth - currentSeason.startDay + 1
val daysFromStartNext = nextSeason.startMonth * 30 + nextSeason.startDay - 15
daysRemaining + daysFromStartNext
}
if (totalDays <= 0) totalDays = 90 // fallback
val currentPos = month * 100 + day
var elapsedDays: Int
if (endPos > startPos) {
elapsedDays = daysBetween(currentSeason.startMonth, currentSeason.startDay, month, day)
} else {
val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, 1)
elapsedDays = (daysInCurrentMonth - currentSeason.startDay + 1) + (day - 1)
}
if (elapsedDays < 0) elapsedDays = 0
val fraction = elapsedDays.toDouble() / totalDays.coerceAtLeast(1)
// Map to angle: 0 at top (Summer Solstice), clockwise
return fraction * 2 * PI
}
private fun daysInMonth(month: Int, day: Int): Int {
val cal = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.MONTH, month - 1)
}
return cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH)
}
private fun daysBetween(m1: Int, d1: Int, m2: Int, d2: Int): Int {
val start = java.util.GregorianCalendar(2024, m1 - 1, d1).timeInMillis
val end = java.util.GregorianCalendar(2024, m2 - 1, d2).timeInMillis
return ((end - start) / (1000 * 60 * 60 * 24)).toInt()
}
/**
* Get the angle for a season's center on the wheel.
* 0 radians = top of wheel, increasing clockwise.
*/
fun angleFor(idx: Int): Double {
return (idx.toDouble() / Segments) * 2 * PI
}
/**
* Get the sweep angle for each wedge (360 degrees / 8 = 45 degrees).
*/
val wedgeSweep: Double
get() = 2 * PI / Segments
}
}
/**
* Color palette for the Celtic Wheel UI.
*/
object SeasonColors {
val Background = Color(0xFF1A1A2E)
val CenterBg = Color(0xFF16213E)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB0BEC5)
val Divider = Color(0xFF37474F)
val Shadow = Color(0x4D000000)
}
/**
* Current date/time info for the wheel hand.
*/
data class HandInfo(
val dayOfYear: Int,
val totalDaysInYear: Int,
val fractionThroughSeason: Double,
val currentSeason: Season
) {
val angle: Double
get() = (dayOfYear.toDouble() / totalDaysInYear) * 2 * Math.PI
}
+97
View File
@@ -0,0 +1,97 @@
package com.hyperling.weather
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.encodeURLParameter
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Repository for fetching weather data from OpenMeteo API.
* OpenMeteo is free and does not require an API key for basic usage.
*/
object WeatherRepository {
private const val OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
private lateinit var httpClient: HttpClient
fun init(engine: HttpClientEngine) {
httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(json)
}
}
}
/**
* Fetch weather data for a location by coordinates.
* Returns the parsed OpenMeteo daily forecast response.
*/
suspend fun fetchWeather(latitude: Double, longitude: Double): OpenMeteoResponse {
return httpClient.get(OPEN_METEO_URL) {
url {
parameters.append("latitude", latitude.toString())
parameters.append("longitude", longitude.toString())
parameters.append("daily", "temperature_2m_max,temperature_2m_min")
parameters.append("forecast_days", "7")
parameters.append("timezone", "auto")
}
}.body()
}
/**
* Parse the raw API response into our domain models.
*/
fun parseDailyWeather(response: OpenMeteoResponse): List<DailyWeather> {
val dates = response.daily.time ?: emptyList()
val highs = response.daily.temperature_2m_max ?: emptyList()
val lows = response.daily.temperature_2m_min ?: emptyList()
return dates.mapIndexed { index, date ->
DailyWeather(
date = date,
tempMaxCelsius = if (index < highs.size) highs[index] else null,
tempMinCelsius = if (index < lows.size) lows[index] else null,
)
}
}
/**
* Convert a location entry to weather data.
*/
suspend fun getWeatherForLocation(location: LocationEntry): Result<LocationWeather> {
return try {
val response = if (location.zipCode.isNotBlank()) {
// OpenMeteo doesn't support zip code lookup directly,
// but we can use the lat/long which should be populated
fetchWeather(location.latitude, location.longitude)
} else {
fetchWeather(location.latitude, location.longitude)
}
val daily = parseDailyWeather(response)
Result.success(
LocationWeather(
locationId = location.id,
zipCode = location.zipCode,
nickname = location.nickname,
latitude = location.latitude,
longitude = location.longitude,
dailyWeather = daily,
lastUpdatedMillis = System.currentTimeMillis(),
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,50 @@
package com.hyperling.weather
import android.content.Context
/**
* SharedPreferences-based local storage manager for location entries (Android).
*/
class AndroidStorageBackend(private val context: Context) : StorageBackend {
private companion object {
const val PREFS_NAME = "nomadic_weather_prefs"
const val KEY_LOCATIONS = "locations_json"
}
private val prefs by lazy {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
override fun load(): List<LocationEntry> {
val jsonStr = prefs.getString(KEY_LOCATIONS, null) ?: return emptyList()
if (jsonStr.isEmpty()) return emptyList()
return try {
LocationSerializer.jsonToLocations(jsonStr)
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
prefs.edit().putString(KEY_LOCATIONS, LocationSerializer.locationsToJson(locations)).apply()
}
override fun saveOne(location: LocationEntry) {
val existing = load().toMutableList()
val idx = existing.indexOfFirst { it == location }
if (idx >= 0) existing[idx] = location
else {
if (existing.size >= 8) existing.removeAt(0)
existing.add(location)
}
save(existing)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
actual fun LocationStorage.getStorage(): StorageBackend =
throw UnsupportedOperationException("getStorage requires AndroidContext - this is a desktop build")
@@ -0,0 +1,6 @@
package com.hyperling.weather
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.engine.HttpClientEngine
actual fun getEngine(): HttpClientEngine = OkHttp.create()
@@ -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()
+49
View File
@@ -0,0 +1,49 @@
package com.hyperling.weather
import java.io.File
/**
* File-based local storage manager for location entries (JVM/Desktop).
*/
class JvmStorageBackend : StorageBackend {
private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather")
private val STORAGE_FILE = File(STORAGE_DIR, "locations.json")
init {
STORAGE_DIR.mkdirs()
}
override fun load(): List<LocationEntry> {
return try {
if (STORAGE_FILE.exists()) {
LocationSerializer.jsonToLocations(STORAGE_FILE.readText())
} else emptyList()
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
try {
STORAGE_DIR.mkdirs()
STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations))
} catch (e: Exception) {}
}
override fun saveOne(location: LocationEntry) {
val existing = load().toMutableList()
val idx = existing.indexOfFirst { it == location }
if (idx >= 0) existing[idx] = location
else {
if (existing.size >= 8) existing.removeAt(0)
existing.add(location)
}
save(existing)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
actual fun LocationStorage.getStorage(): StorageBackend = JvmStorageBackend()
+6
View File
@@ -0,0 +1,6 @@
package com.hyperling.weather
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.HttpClientEngine
actual fun getEngine(): HttpClientEngine = CIO.create()
+7
View File
@@ -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()
+59
View File
@@ -0,0 +1,59 @@
package com.hyperling.weather
import java.io.File
/**
* JVM Implementation of StorageBackend for Desktop use.
*/
class JvmStorageBackendImpl : StorageBackend {
private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather")
private val STORAGE_FILE = File(STORAGE_DIR, "locations.json")
init {
STORAGE_DIR.mkdirs()
}
override fun load(): List<LocationEntry> {
return try {
if (STORAGE_FILE.exists()) LocationSerializer.jsonToLocations(STORAGE_FILE.readText()) else emptyList()
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
try {
STORAGE_DIR.mkdirs()
STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations))
} catch (e: Exception) {}
}
override fun saveOne(location: LocationEntry) {
val ex = load().toMutableList()
val idx = ex.indexOfFirst { it == location }
if (idx >= 0) ex[idx] = location
else {
if (ex.size >= 8) ex.removeAt(0)
ex.add(location)
}
save(ex)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
/**
* JVM-specific platform initialization.
*/
class JvmPlatformInit : PlatformInit {
override fun initialize() {
val dir = File(System.getProperty("user.home"), ".nomadic-weather")
if (!dir.exists()) dir.mkdirs()
println("Nomadic Weather Desktop Initialized")
}
}
actual fun getPlatformStorage(): StorageBackend = JvmStorageBackendImpl()
actual fun getPlatformInit(): PlatformInit = JvmPlatformInit()
+17
View File
@@ -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())
}
}
@@ -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())
}
}
+11
View File
@@ -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())
}
}
View File
Whitespace-only changes.