Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
077de9d42f | ||
|
|
337403721b | ||
|
|
eeda4a18c5 | ||
|
|
ac88d08213 | ||
|
|
ccf6e69054 | ||
|
|
50ae6a0f66 | ||
|
|
152758e03b | ||
|
|
1bd33a768f | ||
|
|
5601cd0f56 | ||
|
|
cbca8ac5d2 | ||
|
|
4bed4e5aff | ||
|
|
258d5e9e0a | ||
|
|
acb4f38f71 | ||
|
|
41c80130d5 | ||
|
|
5bb9e51bfe | ||
|
|
0ccd900219 | ||
|
|
f433cd87a8 | ||
|
|
68aba793b9 |
No files matched your search
@@ -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.
|
||||
@@ -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/.
|
||||
@@ -5,10 +5,12 @@
|
||||
## Variables ##
|
||||
|
||||
DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||
PROJ="$(cd "$DIR"/.. &>/dev/null && pwd)"
|
||||
PROG="$(basename -- "${BASH_SOURCE[0]}")"
|
||||
LOCAL=~/bin
|
||||
GLOBAL=/usr/local/bin
|
||||
GLOBAL_SRC=/usr/local/src
|
||||
GLOBAL="/usr/local"
|
||||
GLOBAL_BIN="$GLOBAL/bin"
|
||||
GLOBAL_SRC="$GLOBAL/src"
|
||||
|
||||
name_local="prototype.js"
|
||||
name_short="nomad"
|
||||
@@ -19,88 +21,122 @@ name_abs="weather-list"
|
||||
|
||||
function usage {
|
||||
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.
|
||||
|
||||
Parameters)
|
||||
h) Display this message.
|
||||
g) Install globally ($GLOBAL), otherwise local ($LOCAL).
|
||||
a) Abstract name, unrelated to being nomadic. ($name_abs)
|
||||
l) Long name, the full name of the project. ($name_long)
|
||||
a) All names / pseudonyms, rather than choosing from below.
|
||||
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)
|
||||
r) Remove / uninstall the project according to the above flags.
|
||||
|
||||
Examples)
|
||||
- Install to the local user with the abstract name.
|
||||
- Install to the local user with the default name.
|
||||
$PROG
|
||||
- Install to all users with all names.
|
||||
$PROG -alsg
|
||||
$PROG -ga
|
||||
- Remove all instances.
|
||||
$PROG -alsr && $PROG -alsrg
|
||||
$PROG -ra && $PROG -gra
|
||||
|
||||
Happy weather watching! :)
|
||||
|
||||
~ Hyperling
|
||||
|
||||
https://git.hyperling.com/me/nomadic-weather
|
||||
EOF
|
||||
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 ##
|
||||
|
||||
while getopts ":gslarh" arg; do
|
||||
while getopts ":gslabrh" arg; do
|
||||
case $arg in
|
||||
g) global="Y" ;;
|
||||
s) short="Y" ;;
|
||||
l) long="Y" ;;
|
||||
a) abstract="Y" ;;
|
||||
b) abstract="Y" ;;
|
||||
a) all="Y" ;;
|
||||
r) remove="Y" ;;
|
||||
h) usage ;;
|
||||
*) echo "ERROR: Parameter $OPTARG was not recognized." && usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
## Processing ##
|
||||
|
||||
name=""
|
||||
if [[ "$short" == "Y" ]]; then
|
||||
name="$name $name_short"
|
||||
fi
|
||||
if [[ "$long" == "Y" ]]; then
|
||||
name="$name $name_long"
|
||||
fi
|
||||
if [[ "$abstract" == "Y" || ( -z "$short" && -z "$long" ) ]]; then
|
||||
if [[ "$all" == "Y" || "$abstract" == "Y" ]]; then
|
||||
name="$name $name_abs"
|
||||
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=""
|
||||
dir="$LOCAL"
|
||||
src="$DIR"
|
||||
src="$PROJ"
|
||||
if [[ "$global" == "Y" ]]; then
|
||||
dir="$GLOBAL"
|
||||
cmd="sudo"
|
||||
dir="$GLOBAL_BIN"
|
||||
src="$GLOBAL_SRC/`basename "$PROJ"`"
|
||||
echo "Requesting sudo privileges... "
|
||||
sudo echo "Success!"
|
||||
mkdir -pv /usr/local/src
|
||||
sudo cp -Rv "$DIR" $GLOBAL_SRC/
|
||||
src="$GLOBAL_SRC/`basename "$DIR"`"
|
||||
fi
|
||||
|
||||
if [[ -z "$remove" ]]; then
|
||||
cmd="$cmd ln -svf $src/$name_local"
|
||||
cmd="$cmd ln -svf $src/cli/$name_local"
|
||||
else
|
||||
cmd="$cmd rm -v"
|
||||
fi
|
||||
|
||||
## Main ##
|
||||
|
||||
# Untested
|
||||
#if [[ "$global" == "Y" && "$remove" == "Y" ]]; then
|
||||
# sudo rm -rfv "$src"
|
||||
#fi
|
||||
# Global Source Code: Install / Removal
|
||||
if [[ "$global" == "Y" ]]; then
|
||||
if [[ "$remove" == "Y" ]]; then
|
||||
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
|
||||
mkdir -pv "$dir"
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
mkdir -pv "$dir"
|
||||
fi
|
||||
$cmd "$dir/$prog"
|
||||
done
|
||||
|
||||
|
||||
@@ -23,63 +23,48 @@
|
||||
* - 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 { hideBin } = require('yargs/helpers');
|
||||
|
||||
const version = '0.0.1';
|
||||
/* * * Variables * * */
|
||||
|
||||
const version = '0.1.2';
|
||||
|
||||
const days_fore_max = 16;
|
||||
const days_fore_default = 12;
|
||||
const days_past_default = 2;
|
||||
const days_fore_default = 7;
|
||||
const days_past_default = 7;
|
||||
|
||||
const ft_to_m = 0.3048;
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.option('days_fore', {
|
||||
alias: 'f',
|
||||
describe: 'Set forecast days, such as "-f 10". Maximum of 16.',
|
||||
type: 'number',
|
||||
requiresArg: true,
|
||||
})
|
||||
.option('days_past', {
|
||||
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
|
||||
;
|
||||
const RED = "\x1b[31m";
|
||||
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 max (value, upper) {
|
||||
return (value > upper) ? upper : value;
|
||||
}
|
||||
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 pref_high_upper = 75;
|
||||
const pref_high_lower = 60;
|
||||
const pref_low_upper = 55;
|
||||
const pref_low_lower = 40;
|
||||
|
||||
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.");
|
||||
const tolerance_light = 10;
|
||||
const tolerance_heavy = 20;
|
||||
|
||||
const locations = [
|
||||
//{ 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: ++", lat: 33.9897, lon: -114.3550, elevation: 300 },
|
||||
{ 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);
|
||||
|
||||
const RED = "\x1b[31m";
|
||||
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";
|
||||
/* * * Functions * * */
|
||||
|
||||
function highlight(value, min, max) {
|
||||
const n = parseFloat(value);
|
||||
return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value;
|
||||
}
|
||||
|
||||
const pref_high_upper = 75;
|
||||
const pref_high_lower = 60;
|
||||
const pref_low_upper = 55;
|
||||
const pref_low_lower = 40;
|
||||
|
||||
const tolerance_light = 10;
|
||||
const tolerance_heavy = 20;
|
||||
function pad(str, width) {
|
||||
const visible = str.replace(/\x1b\[\d+m/g, '').length;
|
||||
return ' '.repeat(Math.max(0, width - visible)) + str;
|
||||
}
|
||||
|
||||
function check (value, min, max) {
|
||||
const n = parseFloat(value);
|
||||
@@ -156,11 +134,60 @@ function check (value, min, max) {
|
||||
//return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value;
|
||||
}
|
||||
|
||||
function pad(str, width) {
|
||||
const visible = str.replace(/\x1b\[\d+m/g, '').length;
|
||||
return ' '.repeat(Math.max(0, width - visible)) + str;
|
||||
function max (value, upper) {
|
||||
return (value > upper) ? upper : value;
|
||||
}
|
||||
|
||||
/* * * 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) {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", loc.lat);
|
||||
@@ -213,3 +240,5 @@ async function getForecast(loc) {
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/* * * Finish * * */
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1 @@
|
||||
Nomadic Weather
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="KotlinToolchainAndroidProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/).
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">NomadicWeather</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" "$@"
|
||||
@@ -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%
|
||||
@@ -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" }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.hyperling.weather
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -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>?,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.hyperling.weather
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||