Compare commits

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

No files matched your search

+639
View File
@@ -0,0 +1,639 @@
---
include_toc: true
gitea: none
---
# Purpose
To allow users to enter up to 8 locations and have the highs and lows display on a Celtic Wheel type layout in order to provide an easy at-a-glance evaluation of where to move during a weather focused nomadic cycle.
### High-Level Description
Simple UI with a clock-like display.
8 clickable pie wedges allow the user to choose a location for weather to be fetched.
| | SS | CQ | |
| --- | --- | --- | --- |
| CQ | | | FE |
| SE | | | CQ |
| | CQ | WS | |
## Definitions
The wheel is based on the movement of the sun and determines the seasons of the year.
SS = Summer Solstice,
CQ = Cross Quarter,
FE = Fall Equinox,
WS = Winter Solstice,
SE = Spring Equinox
Summer is placed at the top to represent high elevation.
A cross quarter is the midway point between each solar location.
The seasons are then places clockwise so that fall and spring are in middle elevations and winter is low elevation.
The solar locations can be calculated roughly as `month = 3*X`, `day = 21`.
The day is an estimate which typically actually spans between the 21st and 24th.
Cross quarters are calculated to be `month = (3*X)-1`, `day = 1`.
An example is Samhein which is typically November 1.
This document will refer to the 8 season segments as follows:
| Season | Abbr. | Events | Dates |
| -- | :-: | :----: | :---: |
| Spring1 | S1 | Spring Equinox to Spring Cross Quarter | March 21 - May 01 |
| Spring2 | S2 | Spring Cross Quarter to Summer Solstice | May 01 - June 21 |
| Summer1 | V1 | Summer Solstice to Summer Cross Quarter | June 21 - August 01 |
| Summer2 | V2 | Summer Cross Quarter to Summer Equinox | August 01 - September 21 |
| Fall1 | F1 | Fall Equinox to Fall Cross Quarter | September 21 - November 01 |
| Fall2 | F2 | Fall Cross Quarter to Winter Solstice | November 01 - December 21 |
| Winter1 | W1 | Winter Solstice to Winter Cross Quarter | December 21 - February 01 |
| Winter2 | W2 | Winter Cross Quarter to Spring Equinox | February 01 - March 21 |
# Components
## 1. Front End
Developer: Hyperling
Language/Framework: Flutter
### Planned Versions
**0.1**
: Basic listview app, hardcode limited production API key.
**0.2**
: Change main page to a Celtic Wheel type layout with season signifier.
**1.0**
: Integrate with a backend cache server which provides aggregated data.
**1.1**
: Add settings page and other nice to haves.
### Requirements
TBD?
### Pages
- App shall have a simple layout with all pages linked and visible from Main.
- Data entry may be done in a pop-up, modal, or subpage if necessary.
1. Main Page
- Clock-like interface which has a pointer hand going towards the current date.
- 12:00 is summer cross quarter, August 1.
1. Location Entry
- Opens when a pie wedge is clicked.
- Allows the user to enter a zip_code OR lat_num + long_num. Not both.
- Allows the user to enter a location_nickname for local use only.
- Saves these fields in simple cache. Data stays local.
#: 1. Settings? [TBD: Maybe a future version? KISS for now.]
#### Main
*Initial Versions - Simple Data Entry*
---
8 sections which contain data input fields for:
- Zip Code
- Lat & Long (used to look up the zip, not sent to cache DB or weather service)
- Nickname
Zip is a required field to successfully submit the data. Nickname is optional.
Lat and Long can be ignored and will be autopopulated on submit based on the Zip,
or if they are both populated and Zip is blank, then the submit function shall
calculate Zip based off of them.
*Formal Versions - Wheel Layout*
---
Includes and expands upon the previous section.
Circular layout representing the Celtic season wheel with 'LAST_UPDATE_DATE'
in the center so the user knows when the highs and lows were last refreshed.
"Hand" which points to the current season. Accurate to the day preferably, so
if we're at September 1 it's pointing in between Summer CQ and Fall Equinox.
- Initially can just highlight the current half quarter and start making the next
one glow as it gets approached. So for June 21, Spring2 and Summer1 would both
glow at 50% since that's the day they transition. Then by mid July it would only
be Summer1 which glows, and at the end of July Summer1 would be dim and Summer2
would be glowing more. Maybe keep this in play after the hand works too.
- Hand and glowing need to be in the opposite direction if the user has more
than half of their latitudes as negatives.
##### Actions
- Location Submission {submitLocation(), onLocationSave()}
- `IF zip.isEmpty() && lat.isEmpty() && long.isEmpty()
THEN ERROR('Zip code must be provided! You may use lat/long to find it if needed.');
FI`
- `IF zip.isEmpty() && (lat.isEmpty() || long.isEmpty())
THEN ERROR('Coordinate data not filled out completely.');
FI`
- `IF zip.isEmpty() && !lat.isEmpty() && !long.isEmpty()
THEN zip = LOOKUP(lat, long);
FI`
- If the user opts to provide a lat and long, find the zip code.
- `IF !zip.isEmpty()
THEN (lat, long) = LOOKUP(zip);
FI`
- This overwrites the user's lat and long for what is actually going to
checked by the weather API.
#### Settings
**Not required in initial versions of the app, focus on pulling data first!**
1. 'Server'
- Toggle: 'Use Cache Server'
- TRUE:
- Radio Group: 'Aggregation Mode'
- 'Week : Mean / Average'
- 'Week : Median / Center-Most'
- 'Week : Mode / Most Common'
- 'Only : Today'
- 'Only : Tomorrow'
- 'Only : Extended (Today + 7)'
- FALSE:
- TextEdit: 'API Key' (REQUIRED)
- 'If not using the cache server, you'll need to provide your own API key for TBD weather service.'
- TBD: What data is provided, and how easy is it to manipulate?
1. 'Temperature'
- Radio Group: 'Unit'
- Fahrenheit (^F)
- Celsius (^C)
- TextEdit: 'Preferred High'
- Whole Numbers / Integers Only
- Radio Group: '(TBD: Relativity?)'
- Option: 'Less Than'
- Option: 'Greater Than'
- Checkbox: 'Equal To'
- TextEdit: 'Preferred Low'
- Whole Numbers / Integers Only
- Radio Group: '(TBD: Relativity?)'
- Option: 'Less Than'
- Option: 'Greater Than'
- Checkbox: 'Equal To'
### Test Cases
1. App opens and displays any previously entered locations.
- Screenshot of app
1. App loads weather data, choosing the DB cache first.
1. If the DB is down, app accesses weather API directly.
- Take down the DB, or put in an incorrect URL/port.
- Open app and note a few temps and the last_update_date.
- After the update takes place, note the new temps and last_update_date.
- App shall start with a Main page only, then have a Settings page added later.
- App shall come with a limited use API key. This key shall not be used by any
other module of this project. Developers should use their separate API key when
using Postman, testing the Weather Fetcher, etc.
## 2. Database
TBD
### Requirements
- All temperature data shall be stored in Fahrenheit. Decimals are okay.
- All date data shall be displayed as 'YYYY-MM-DDTHH24:MI:SS' GMT
(or 'YYYY-MM-DDTHH24:MI:SS+0000') in any reporting or data transfers. Time zones
will be handled at the frontend. The 'T' in the center is a dead character. Example:
```shell
$ echo "Actual Time: `date`" && echo "Report Time: `date -u +'%Y-%m-%dT%H:%M:%S%z'`"
Actual Time: Wed Aug 19 10:49:52 AM MST 2026
Report Time: 2026-08-19T17:49:52+0000
```
- Database shall be set to case insensitive, so that `SELECT column FROM loc`
and `select COLUMN from LOC` are the same command.
- Database shall have a daily purge script followed by statistics rebuilding.
The records to be removed will look like (TBD: `IS_PURGED = true`). The purge
will cascade through any child tables. Bonus points if they exist, are scheduled,
and gets executed from inside the DB rather than needing to run outside files.
- Database object script(s) for the primary initialization as well as patches
shall be reproducible and rerunnable, meaning they drop then create all database
objects, constraints, schedules, etc being added in the revision. Example:
```sql
-- Undo Section --
-- Allowed to have failures if the objects don't exist yet.
SET ERRORS OFF / ON ERROR CONTINUE / ETC;
DROP TABLE example_child;
DROP TABLE example;
-- Apply Section --
-- All commands must succeed successfully in order to continue.
SET ERRORS ON / ON ERROR EXIT / ETC;
CREATE TABLE example
( example_id INT PRIMARY KEY
, example_data VARCHAR
);
CREATE TABLE example_child
( example_child_id INT PRIMARY KEY
, example_id INT FOREIGN KEY (example.example_id)
, more_example_data VARCHAR
);
```
- Scripts shall have a consistent style and formatting. Developer may use the
style of their choice.
- Scripts shall assume a Docker environment using image `debian:trixie-slim`.
### Tables
- Database and schema names shall be short and easy to remember, otherwise they
are the developer's choice. Bonus points if the DB does not require them to
access the tables, or a setting / parameter can be set to default to them.
```sql
-- yucky
select * from db_name.schema_name.table_name;
-- preferred
select * from table_name;
```
#### Table 1: `loc`
| Column Name | Type | Constraints | Calculated? |
| --- | :-: | :---: | :-----: |
| loc_id | INT | PK | - |
| zip_code | STRING | UNIQUE | - |
| last_accessed_date | DATE | - | - |
| needs_purged | - | - | CASE WHEN sysdate < last_accessed_date + 90 THEN true ELSE false ESAC |
#### Table 2: `loc_weather`
| Column Name | Type | Constraints? | Calculated? |
| -- | :-: | :-: | :---: |
| loc_weather_id | INT | PK,UNIQUE | |
| loc_id | INT | FK | |
| weather_date | DATE | | |
| isCurrent | - | | `if weather_date >= sysdate then 'Y' else 'N' fi` |
| isOld | - | | `if weather_date < sysdate then 'Y' else 'N' fi` |
| isExpired | - | | `if weather_date < sysdate-7 then 'Y' else 'N' fi` |
| Extra Constraints |
| --- |
| UNIQUE(loc_id, weather_date) |#: TBD
#### Table 3: `loc_weather_agg`
| Column Name | Type | Constraints | Calculated? |
| --- | :-: | :---: | :-----: |
| loc_weather_agg_id | INT | PK | - |
| loc_weather_id | INT | FK (loc_weather.loc_weather_id) | - |
| weekly_mean_high | DECIMAL | - | - |
| weekly_mean_low | DECIMAL | - | - |
| weekly_median_high | DECIMAL | - | - |
| weekly_median_low | DECIMAL | - | - |
| weekly_mode_high | DECIMAL | - | - |
| weekly_mode_low | DECIMAL | - | - |
| tomorrow_high | DECIMAL | - | - |
| tomorrow_low | DECIMAL | - | - |
| extended_high | DECIMAL | - | - |
| extended_low | DECIMAL | - | - |
Today's high and low are accessible when joining to the `LOC_WEATHER` table, which
is necessary anyways to get to the `WEATHER_DATE` and `ZIP_CODE`.
Extended high and low are for the furthest accessible day up to a week out.
```sql
SELECT loc_weather_id, max(weather_date)
FROM loc_weather
WHERE weather_date <= sysdate+7
GROUP BY loc_weather_id;
```
### Roles
Object grants shall be given to roles, and the roles then assigned to users. Example:
```sql
CREATE ROLE myrole;
GRANT SELECT, INSERT, UPDATE ON example TO myrole;
CREATE USER me;
GRANT myrole TO me;
```
#### Role 0 : `librarian`
- Grant `SELECT` on all `loc*` tables.
#### Role 1: `weatherman`
- Grant `INSERT` and `UPDATE` on all `loc_weather*` tables.
#### Role 2: `reporter`
- Grant `INSERT`, `UPDATE` on `loc`.
#### Role 3: `garbageman`
- Grant `DELETE` on all `loc*` tables.
- Grant ability to build statistics on all `loc*` tables.
- Depending on the DB used, may also need grants on keys and constraints in order to rebuild the stats on their indexes or other stat-holding objects.
#### Role 4: `developer`
- Grant `ALL` on the applicable schema.
- CANNOT access or alter `SYSTEM` type schemas, views, etc.
### Users
#### User 1: `dbuser`
- Granted the `librarian` and `developer` role.
- Used by the main contributors of the project to test in stage environments.
- Used by the project owner(s) in production to apply patch scripts.
#### User 2: `climate`
- Granted the `librarian` and `weatherman` role.
- Used by a local process to add and aggregate weather data.
#### User 3: `geo`
- Granted the `librarian` and `reporter` role.
- Used by a local process to pull weather data and post new locations.
#### User 4: `purge`
- Granted the `librarian` and `garbageman` role.
- User / schema who owns the purge routines if they are installed in the DB.
- Otherwise, this is the user who logs in and runs external purge scripts.
#### As Needed: leetbro, hackersis, othercoolalias
Contributors to this project may have their accounts created in test and stage
environments as necessary. Otherwise the contributors known as DB admins shall
handle all applicable DB support and modifications.
### Test Cases
TBD
## 3. Weather Fetcher
TBD
### Description
TBD
### Requirements
TBD
### Test Cases
- Assume the database is local, just as would be during local development. Will
not need to access via domain name or a special port, only localhost:defaut_port.
- Program shall use its own API key(s) to access the weather data. May use a
pool of keys if necessary to alleviate bandwidth or throttling.
#### Aggregations
For each `ZIP_CODE`, calculate the following:
- Weekly Mean
- Weekly Median
- Weekly Mode
- Tomorrow
```sql
-- Can be done the same as Extended, just change the sysdate+7 to sysdate+1.
```
- Extended
- Example:
```sql
-- Need to get the max(weather_date) per loc_id, identify its loc_weather_id, then use that to join against the agg table.
-- Temp view of the latest date that each location has.
SELECT lw.loc_id, max(lw.weather_date)
FROM loc_weather lw
WHERE lw.weather_date <= sysdate + 7
GROUP BY lw.loc_id;
-- Joining the temp view to the table to get the individual records.
SELECT lw.loc_weather_id, lw.high, lw.low
FROM loc_weather lw
, (SELECT lw.loc_id, max(lw.weather_date) as weather_date
FROM loc_weather lw
WHERE lw.weather_date <= sysdate + 7
GROUP BY lw.loc_id) t
WHERE lw.loc_id = t.loc_id
AND lw.weather_date = t.weather_date;
-- Merging the data into the agg table.
MERGE INTO loc_weather_agg o
USING (SELECT lw.loc_weather_id, lw.high, lw.low
FROM loc_weather lw
, (SELECT lw.loc_id, max(lw.weather_date) as max_weather_date
FROM loc_weather lw
WHERE lw.weather_date <= sysdate + 7
GROUP BY lw.loc_id) t
WHERE lw.loc_id = t.loc_id
AND lw.weather_date = t.max_weather_date) i
ON (o.loc_weather_id = i.loc_weather_id)
WHEN MATCHED THEN UPDATE
SET o.extended_high = i.high
, o.extended_low = i.low;
commit;
```
## 4. Response API
Developer: TBD
Language: Developer's choice.
Node.js using Express recommended unless a better use case can be made.
### Description
Listener API which receives requests from the front end and queries the database on their behalf. This program is responsible for adding new `ZIP_CODE`'s to the database and requesting that the weatherman load the data for these when that is done.
### Requirements
Handles requests from front end devices.
### Test Cases
- Program shall listen JSON #1 in the appendix as its initiating request.
- Program shall provide JSON #2 as a response to the request.
- Program shall access the database in section #2 as user `geo`.
- Program shall acquire the weather data from the `loc_weather_agg` table..
- Program shall post any new zip codes in the `loc` table.
- program shall update `loc.last_accessed_date` for the zip codes it retrieved data for.
### Flow
1. Initialize.
1. Parse request. (Appendix #1)
1. Merge zip codes to `loc`.
1. Notify `weatherman` if new zip codes exist.
- Wait until a continue or error is replied.
- If continue, proceed to next steps.
- If error, provide error to user (Appendix #8)
1. Pull weather data from `loc_weather` and `loc_weather_agg`.
1. Form and deliver response. (Appendix #2)
# Appendix - JSON Requests and Responses
Between the programs being developed there are 3 themes being used regarding message intent.
**data**
: These messages pass information back and forth.
**command**
: These are used to notify programs that actions need taken.
**error**
: These notify that something went wrong and may provide a message with which to return to the front end.
Theintent is passed at the JSON object's root level `"message_type"` attribute. The rest of the payload lies within an attribute named the same as the message type.
## 1. Request: Front End to Response API
- Data shall be de-duplicated so that any one zip only appears once.
- All payloads shall contain the version number so that the server knows which
response to provide, in case the format changes in the future.
- Commas are acceptable after every data element, even ending ones.
```json
{
"version": "1.0",
"message_type": "data",
"data": [
"zip_code": "85901",
"zip_code": "86004",
"zip_code": "86001",
],
}
```
## 2. Response: Response API to Front End
- Version shall be included so that the front end knows how to parse the response.
- Commas are acceptable after every element, including the ends of lists.
- Zip codes and dates shall be treated as strings.
- Temperature data shall be treated like numbers.
```json
{
"version": "1.0",
"message_type": "data",
"data": [
{
"zip_code": "85901",
"last_update_date": "1900-01-01T15:00:00+0000",
"mean_high": 64.237,
"mean_low": 38.758,
"median_high": 65,
"median_low": 43,
"mode_high": 68,
"mode_low": 47,
"today_high": 58,
"today_low": 33,
"tomorrow_high": 61,
"tomorrow_low": 37,
},
{
"zip_code": "86004",
"last_update_date": "1900-01-01T15:00:00+0000",
"mean_high": 45.46,
"mean_low": 22.235,
"median_high": 45,
"median_low": 19,
"mode_high": 42,
"mode_low": 22,
"today_high": 46,
"today_low": 25,
"tomorrow_high": 52,
"tomorrow_low": 12,
},
{
"zip_code": "86001",
"last_update_date": "1900-01-01T15:00:00+0000",
"mean_high": 45.64,
"mean_low": 22.532,
"median_high": 44,
"median_low": 18,
"mode_high": 41,
"mode_low": 21,
"today_high": 45,
"today_low": 24,
"tomorrow_high": 51,
"tomorrow_low": 11,
},
],
}
```
## 3. Request: Applications to Weather Provider (TBD)
(TBD: How to parse a request to the weather service.)
## 4. Response: Weather Provider (TBD) to Applications
(TBD: How to read a response from the weather service.)
## 5. Request: New ZIP_CODE Needs Processed
Notification from the `reporter` for the `weatherman` to kick off an ad hoc run.
- This data never leaves the server and cannot come from outside sources, the endpoint will not available to the public, it is accessed via localhost.
- Zip codes do not need provided since the `reporter` has already inserted and committed them to `loc`.
- `reporter` runs this in a synchronous fashion, waiting for the response before continuing to reply to the user.
```json
{
"message_type": "command",
"command": "run",
}
```
## 6. Response: ZIP_CODE Data Now Available
Response from `weatherman` program to `reporter` that the new zip codes inserted into the DB have data.
- If the `reporter` sees this, then it continues with building a response for the front end user.
```json
{
"message_type": "command",
"command": "continue",
}
```
## 7. Response: Issue Loading New ZIP_CODE's
Response from `weatherman` program to `reporter`.
If the `reporter` happens to see this, it provides
```json
{
"message_type": "error",
"error": "Message about what went wrong, which may get forwarded to the user.",
}
```
## 8. Response: Error to Front End
Sometimes the `reporter` may need to let the user know things did not go as planned.
```json
{
"message_type": "error",
"error": "Failed to pull weather information. REASON.",
}
```
+18 -2
View File
@@ -1,3 +1,19 @@
# template-mit
# Nomadic Weather
Template with MIT license, protected `main` branch, and available `dev` branch.
See the [wiki](https://git.hyperling.com/me/nomadic-weather/wiki) for more information.
## cli
Programs made while testing with the API endpoint.
## doc
Documentation.
## gui
Flutter project directed at Android and Linux.
## srv
Backend code.
+18
View File
@@ -0,0 +1,18 @@
# Nomadic Weather CLI
Prototype for playing with [Open-Meteo](https://open-meteo.com).
Locations need modified manually since it's still just a prototype.
## prototype.js
Got a little carried away and made it pretty formal.
Still a prototype though since the locations are hardcoded.
Would be made more complete by accepting a list of values, an env file,
or basically just any way of handling the locations as user input.
## install.sh
Allows the script to be installed either in ~/bin/ or /usr/local/bin/.
Also handles removal if it is no longer wanted.
Currently does not remove from /usr/local/src/.
+65 -29
View File
@@ -5,10 +5,12 @@
## Variables ##
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
if [[ ! -d "$dir" ]]; then
mkdir -pv "$dir"
fi
$cmd "$dir/$prog"
done
+94 -65
View File
@@ -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 * * */
View File
Whitespace-only changes.
+11
View File
@@ -0,0 +1,11 @@
*.iml
.kotlin
build/
.DS_Store
captures
**/module.xcodeproj/*
!**/module.xcodeproj/project.pbxproj
!**/module.xcodeproj/xcshareddata/
!**/module.xcodeproj/project.xcworkspace/
**/xcshareddata/WorkspaceSettings.xcsettings
xcuserdata
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
Nomadic Weather
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="KotlinToolchainAndroidProjectSystem" />
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AmperProjectManager">
<option name="amperProject" value="true" />
<option name="projectRoot" value="$PROJECT_DIR$" />
</component>
</project>
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/NomadicWeather.iml" filepath="$PROJECT_DIR$/.idea/NomadicWeather.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+22
View File
@@ -0,0 +1,22 @@
This is a Kotlin Multiplatform project targeting Android, Desktop (JVM), built with the [Kotlin Toolchain](https://kotlin-toolchain.org/dev/).
- [/androidApp](./androidApp) contains the Android application.
- [/desktopApp](./desktopApp) contains the desktop (JVM) application.
- [/shared](./shared) holds the code shared across your applications — Compose UI, business logic, and platform-specific implementations. [src](./shared/src) is for common code; the sibling `src@<platform>` folders (for example `src@android`) hold code compiled only for the platform named in the folder.
The `kotlin` (macOS/Linux) and `kotlin.bat` (Windows) scripts in the project root are self-bootstrapping wrappers for the Kotlin Toolchain: they download the pinned toolchain version on first use, so no separate installation is required. Build the whole project with `./kotlin build`.
### Running
Use the run configurations provided by the run widget in your IDE's toolbar. You can also run each module from the command line:
- Android app: `./kotlin run -m androidApp`
- Desktop app: `./kotlin run -m desktopApp`
### Testing
Run the project's tests with `./kotlin test`, or `./kotlin test -m <module>` for a single module.
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), [Kotlin Toolchain](https://kotlin-toolchain.org/dev/), [Compose Multiplatform](https://kotlinlang.org/compose-multiplatform/).
+13
View File
@@ -0,0 +1,13 @@
product: android/app
dependencies:
- //shared
- $libs.androidx.activity.compose
- $compose.preview
- $compose.uiTooling
settings:
compose: enabled
android:
namespace: com.hyperling.weather
applicationId: com.hyperling.weather
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">NomadicWeather</string>
</resources>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
package com.hyperling.weather
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}
+13
View File
@@ -0,0 +1,13 @@
product: jvm/app
dependencies:
- //shared
- $compose.desktop.currentOs
- $compose.preview
- $libs.kotlinx.coroutines.swing
- $libs.logback.classic
settings:
compose: enabled
jvm:
mainClass: com.hyperling.weather.MainKt
+10
View File
@@ -0,0 +1,10 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
+10
View File
@@ -0,0 +1,10 @@
package com.hyperling.weather
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "NomadicWeather") {
App()
}
}
Executable
+292
View File
@@ -0,0 +1,292 @@
#!/bin/sh
#
# Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
#
# Possible environment variables:
# KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from.
# default: https://packages.jetbrains.team/maven/p/amper/amper
# KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the JRE to run the Kotlin CLI
# default: https:/
# KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution
# KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation)
# KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application)
# KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value
set -e -u
# The version of the Kotlin Toolchain (and CLI) distribution to provision and use
kotlin_cli_version=0.13.0
# Establish chain of trust from here by specifying exact checksum of Kotlin Toolchain (and CLI) distribution to be run
kotlin_cli_sha256=fa82b9872dcfa04d26c8f800f33d597c99fbffd747a0e9bbce3a9e4424b3a588
KOTLIN_CLI_DOWNLOAD_ROOT="${KOTLIN_CLI_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}"
die() {
echo >&2
echo "$@" >&2
echo >&2
exit 1
}
# usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE
# $1 SOURCE_MONIKER (e.g. url)
# $2 FILE
# $3 SHA hex string
# $4 SHA size in bits (256, 512, ...)
check_sha() {
sha_size=$4
if command -v shasum >/dev/null 2>&1; then
echo "$3 *$2" | shasum -a "$sha_size" --status -c || {
die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')"
}
return 0
fi
shaNsumCommand="sha${sha_size}sum"
if command -v "$shaNsumCommand" >/dev/null 2>&1; then
# discard the output as sha*sum may print redundant warnings in some versions
echo "$3 *$2" | $shaNsumCommand -w -c >/dev/null 2>&1 || {
die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')"
}
return 0
fi
echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them"
return 1
}
download_and_extract() {
moniker="$1"
file_url="$2"
file_sha="$3"
sha_size="$4"
cache_dir="$5"
extract_dir="$6"
show_banner_on_cache_miss="$7"
if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then
# Everything is up-to-date in $extract_dir, do nothing
return 0;
fi
mkdir -p "$cache_dir"
# Take a lock for the download of this file
short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh
download_lock_file="$cache_dir/download-${short_sha}.lock"
process_lock_file="$cache_dir/download-${short_sha}.$$.lock"
temp_file="$cache_dir/download-${short_sha}.$$.bin"
echo $$ >"$process_lock_file"
while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do
lock_owner=$(cat "$download_lock_file" 2>/dev/null || true)
# We use `kill -0` instead of `ps -p` as the first one is more portable
if [ -n "$lock_owner" ] && kill -0 "$lock_owner" >/dev/null; then
echo "Another Kotlin CLI instance (pid $lock_owner) is downloading $moniker. Awaiting the result..."
sleep 1
elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then
rm -f "$download_lock_file"
# We don't want to simply loop again here, because multiple concurrent processes may face this at the same time,
# which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just
# ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically
# reduced because it would involve 4 processes with perfect timing. We can revisit this later.
die "Another Kotlin CLI instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again."
fi
done
# An extraction left half-done must never keep the .flag file, which marks $extract_dir as complete: subsequent runs
# would skip the extraction and use the broken directory.
cleanup_incomplete_provisioning() {
rm -f "$download_lock_file" "$temp_file"
rm -rf "$extract_dir"
}
trap cleanup_incomplete_provisioning EXIT
rm -f "$process_lock_file"
unlock_and_cleanup() {
rm -f "$download_lock_file"
trap - EXIT
return 0
}
if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then
# Everything is up-to-date in $extract_dir, just release the lock
unlock_and_cleanup
return 0;
fi
if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${KOTLIN_CLI_NO_WELCOME_BANNER:-}" ]; then
echo
cat <<EOF
Welcome to
@@@ @@@@ @@@ @@@
@@@ @@@@ @@@ @@@ @@@
@@@ #@@@" ,@@@ @@@
@@@ ,@@@% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@,
@@@ @@@@ @@@@@%"%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%@@@@@
@@@@@@% @@@% %@@@ @@@ @@@ @@@ @@@@ %@@%
@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@
@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@
@@@ *@@@% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@
@@@ %@@@= %@@@@*,*@@@@% @@@@### @@@ @@@ @@@ @@@
@@@ @@@@ "@@@@@@@" %@@@@@ @@@ @@@ @@@ @@@
EOF
echo
echo "This is the first run of the Kotlin CLI v$kotlin_cli_version, so we need to download the Kotlin Toolchain."
echo "Please give us a few seconds, subsequent runs will be faster."
echo
fi
echo "Downloading $moniker..."
rm -f "$temp_file"
if command -v curl >/dev/null 2>&1; then
if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi
# shellcheck disable=SC2086
curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url"
elif command -v wget >/dev/null 2>&1; then
if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi
wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url"
else
die "ERROR: Please install 'wget' or 'curl', as one of them is required to download $moniker"
fi
check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
case "$file_url" in
*".zip")
if command -v unzip >/dev/null 2>&1; then
unzip -q "$temp_file" -d "$extract_dir"
else
die "ERROR: Please install 'unzip', which is required to extract $moniker"
fi ;;
*)
if command -v tar >/dev/null 2>&1; then
tar -x -f "$temp_file" -C "$extract_dir"
else
die "ERROR: Please install 'tar', which is required to extract $moniker"
fi ;;
esac
rm -f "$temp_file"
echo "$file_sha" >"$extract_dir/.flag"
# Unlock and cleanup the lock file
unlock_and_cleanup
echo "Download complete."
echo
}
# ********** Project-local version detection **********
# 1. Search upwards for an executable `amper` file and/or `project.yaml`
# Sets wrapper_script to the found wrapper path, or empty string if not found.
find_project_context() {
wrapper_script=""
this_script="$(realpath "$0")"
project_dir=$(pwd)
while [ "$project_dir" != "/" ] && [ -n "$project_dir" ]; do
wrapper_candidate="$project_dir/kotlin"
if [ "$this_script" = "$wrapper_candidate" ]; then
# Found itself (local wrapper case), no need to update any version or search further.
return 1
fi
if [ -f "$wrapper_candidate" ] && [ -x "$wrapper_candidate" ]; then
# Found the wrapper — check that a project context exists alongside it
if [ -f "$project_dir/project.yaml" ] || [ -f "$project_dir/module.yaml" ]; then
wrapper_script="$wrapper_candidate"
return 0
else
echo "WARNING: Found wrapper script '$wrapper_candidate', but no project.yaml or module.yaml near it. Skipping." >&2
# Continue the search
fi
elif [ -f "$project_dir/project.yaml" ]; then
# Found project.yaml but no executable wrapper alongside it
echo "WARNING: Found a project.yaml in '$project_dir', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version." >&2
return 1
fi
project_dir=$(dirname "$project_dir")
done
# Do not check root '/' - it's an unlikely candidate for a project
return 1
}
parse_project_context() {
# Parse kotlin_cli_version and kotlin_cli_sha256 from "$wrapper_script" without executing it.
parsed_kotlin_cli_version=$(
sed -n 's/^kotlin_cli_version=\([A-Za-z0-9._+-]\{1,\}\)[[:space:]]*$/\1/p' "$wrapper_script" \
| head -n 1
)
parsed_kotlin_cli_sha256=$(
sed -n 's/^kotlin_cli_sha256=\([0-9a-fA-F]\{64\}\)[[:space:]]*$/\1/p' "$wrapper_script" \
| head -n 1
)
if [ -z "$parsed_kotlin_cli_version" ]; then
echo "ERROR: Suspicious local wrapper script: failed to detect the distribution version in '$wrapper_script'" >&2
return 1
fi
if [ -z "$parsed_kotlin_cli_sha256" ]; then
echo "ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '$wrapper_script'" >&2
return 1
fi
# overwrite builtin values and proceed
kotlin_cli_version=$parsed_kotlin_cli_version
kotlin_cli_sha256=$parsed_kotlin_cli_sha256
return 0
}
if [ -z "${KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION:-}" ]; then
find_project_context && parse_project_context
fi
# ********** System detection **********
kernelName=$(uname -s)
case "$kernelName" in
Darwin* )
default_kotlin_cli_cache_dir="$HOME/Library/Caches/JetBrains/Kotlin/cli"
;;
Linux* )
default_kotlin_cli_cache_dir="$HOME/.cache/JetBrains/Kotlin/cli"
;;
CYGWIN* | MSYS* | MINGW* )
if command -v cygpath >/dev/null 2>&1; then
default_kotlin_cli_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Kotlin\cli")
else
die "The 'cypath' command is not available, but the Kotlin CLI needs it. Use kotlin.bat instead, or try a Cygwin or MSYS environment."
fi
;;
*)
die "Unsupported platform $kernelName"
;;
esac
kotlin_cli_cache_dir="${KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:-$default_kotlin_cli_cache_dir}"
# ********** Provision the Kotlin Toolchain distribution **********
kotlin_cli_url="$KOTLIN_CLI_DOWNLOAD_ROOT/org/jetbrains/kotlin/kotlin-cli/$kotlin_cli_version/kotlin-cli-$kotlin_cli_version-dist.tgz"
kotlin_cli_target_dir="$kotlin_cli_cache_dir/kotlin-cli-$kotlin_cli_version"
download_and_extract "Kotlin Toolchain distribution v$kotlin_cli_version" "$kotlin_cli_url" "$kotlin_cli_sha256" 256 "$kotlin_cli_cache_dir" "$kotlin_cli_target_dir" "true"
# ********** Launch the Kotlin CLI **********
launcher_script="$kotlin_cli_target_dir/bin/launcher.sh"
KOTLIN_CLI_WRAPPER_PATH="$(realpath "$0")" \
exec /bin/sh "$launcher_script" "$@"
+280
View File
@@ -0,0 +1,280 @@
@echo off
@rem
@rem Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@rem
@rem Possible environment variables:
@rem KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from
@rem default: https://packages.jetbrains.team/maven/p/amper/amper
@rem KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the Kotlin CLI JRE from.
@rem default: https:/
@rem KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution
@rem KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation)
@rem KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application)
@rem KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value
setlocal
@rem The version of the Kotlin Toolchain distribution to provision and use
set kotlin_cli_version=0.13.0
@rem Establish chain of trust from here by specifying the exact checksum of the Kotlin Toolchain distribution to be run
set kotlin_cli_sha256=fa82b9872dcfa04d26c8f800f33d597c99fbffd747a0e9bbce3a9e4424b3a588
if not defined KOTLIN_CLI_DOWNLOAD_ROOT set KOTLIN_CLI_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper
if not defined KOTLIN_CLI_BOOTSTRAP_CACHE_DIR set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Kotlin\cli
@rem remove trailing \ if present
if [%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~-1%] EQU [\] set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~0,-1%
goto :after_function_declarations
REM ********** Download and extract any zip or .tar.gz archive **********
:download_and_extract
setlocal
set moniker=%~1
set url=%~2
set target_dir=%~3
set sha=%~4
set sha_size=%~5
set show_banner_on_cache_miss=%~6
setlocal enableDelayedExpansion
set flag_file=%target_dir%\.flag
if exist "%flag_file%" (
set /p current_flag=<"%flag_file%"
if "!current_flag!" == "%sha%" exit /b
)
set NL=^
@rem two empty lines required above for the NL character
@rem We have to build the welcome banner here as an env var because we
@rem can't pass a multiline string through the single line powershell
set welcome_banner=!NL! ^
Welcome to !NL! ^
!NL! ^
@@@ @@@@ @@@ @@@ !NL! ^
@@@ @@@@ @@@ @@@ @@@ !NL! ^
@@@ #@@@^" ,@@@ @@@ !NL! ^
@@@ ,@@@%% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@, !NL! ^
@@@ @@@@ @@@@@%%^"%%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%%%@@@@@ !NL! ^
@@@@@@%% @@@%% %%@@@ @@@ @@@ @@@ @@@@ %%@@%% !NL! ^
@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^
@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^
@@@ *@@@%% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@ !NL! ^
@@@ %%@@@= %%@@@@*,*@@@@%% @@@@### @@@ @@@ @@@ @@@ !NL! ^
@@@ @@@@ ^"@@@@@@@^" %%@@@@@ @@@ @@@ @@@ @@@ !NL!
@rem This multiline string is actually passed as a single line to powershell, meaning #-comments are not possible.
@rem So here are a few comments about the code below:
@rem - we need to support both .zip and .tar.gz archives (for the Kotlin Toolchain distribution and the JRE)
@rem - tar should be present in all Windows machines since 2018 (and usable from both cmd and powershell)
@rem - tar requires the destination dir to exist
@rem - native commands like tar don't raise PowerShell errors on failure, so we have to check $LASTEXITCODE.
@rem The extraction must never be left half-done with the .flag file in place, because the .flag file marks the
@rem target dir as complete, and subsequent runs would then skip the extraction and use the broken directory.
@rem - We use (New-Object Net.WebClient).DownloadFile instead of Invoke-WebRequest for performance. See the issue
@rem https://github.com/PowerShell/PowerShell/issues/16914, which is still not fixed in Windows PowerShell 5.1
@rem - DownloadFile requires the directories in the destination file's path to exist
set download_and_extract_ps1= ^
Set-StrictMode -Version 3.0; ^
$ErrorActionPreference = 'Stop'; ^
^
$temp_file = $null; ^
$createdNew = $false; ^
$lock = New-Object System.Threading.Mutex($true, ('Global\kotlin-cli-bootstrap.' + '%target_dir%'.GetHashCode().ToString()), [ref]$createdNew); ^
if (-not $createdNew) { ^
Write-Host 'Another Kotlin CLI instance is bootstrapping. Waiting for our turn...'; ^
[void]$lock.WaitOne(); ^
} ^
^
try { ^
if ((Get-Content '%flag_file%' -ErrorAction Ignore) -ne '%sha%') { ^
if (('%show_banner_on_cache_miss%' -eq 'true') -and [string]::IsNullOrEmpty('%KOTLIN_CLI_NO_WELCOME_BANNER%')) { ^
Write-Host \"$env:welcome_banner\"; ^
Write-Host ''; ^
Write-Host 'This is the first run of the Kotlin CLI v%kotlin_cli_version%, so we need to download the Kotlin Toolchain.'; ^
Write-Host 'Please give us a few seconds now, subsequent runs will be faster.'; ^
Write-Host ''; ^
} ^
$temp_file = '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\' + [System.IO.Path]::GetRandomFileName(); ^
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ^
Write-Host 'Downloading %moniker%...'; ^
[void](New-Item '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%' -ItemType Directory -Force); ^
if (Get-Command curl.exe -errorAction SilentlyContinue) { ^
curl.exe -L --silent --show-error --fail --output $temp_file '%url%'; ^
} else { ^
(New-Object Net.WebClient).DownloadFile('%url%', $temp_file); ^
} ^
^
$actualSha = (Get-FileHash -Algorithm SHA%sha_size% -Path $temp_file).Hash.ToString(); ^
if ($actualSha -ne '%sha%') { ^
$writeErr = if ($Host.Name -eq 'ConsoleHost') { [Console]::Error.WriteLine } else { $host.ui.WriteErrorLine } ^
$writeErr.Invoke(\"ERROR: Checksum mismatch for $temp_file (downloaded from %url%): expected checksum %sha% but got $actualSha\"); ^
exit 1; ^
} ^
^
if (Test-Path '%target_dir%') { ^
Remove-Item '%target_dir%' -Recurse; ^
} ^
try { ^
if ($temp_file -like '*.zip') { ^
Add-Type -A 'System.IO.Compression.FileSystem'; ^
[IO.Compression.ZipFile]::ExtractToDirectory($temp_file, '%target_dir%'); ^
} else { ^
[void](New-Item '%target_dir%' -ItemType Directory -Force); ^
tar -xzf $temp_file -C '%target_dir%'; ^
if ($LASTEXITCODE -ne 0) { ^
throw \"tar failed to extract %moniker% (exit code $LASTEXITCODE)\"; ^
} ^
} ^
} catch { ^
Remove-Item '%flag_file%' -Force -ErrorAction Ignore; ^
Remove-Item '%target_dir%' -Recurse -Force -ErrorAction Ignore; ^
throw; ^
} ^
^
Set-Content '%flag_file%' -Value '%sha%'; ^
Write-Host 'Download complete.'; ^
Write-Host ''; ^
} ^
} ^
finally { ^
if ($temp_file) { ^
Remove-Item $temp_file -Force -ErrorAction Ignore; ^
} ^
$lock.ReleaseMutex(); ^
}
rem We reset the PSModulePath in case this batch script was called from PowerShell Core
rem See https://github.com/PowerShell/PowerShell/issues/18108#issuecomment-2269703022
set PSModulePath=
set powershell=%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe
"%powershell%" -NonInteractive -NoProfile -NoLogo -Command %download_and_extract_ps1%
if errorlevel 1 exit /b 1
exit /b 0
:find_project_context
@rem Search upwards for a kotlin.bat wrapper file and/or project.yaml
@rem Sets wrapper_script to the found wrapper path, or empty string if not found.
@rem Returns errorlevel 0 if a valid wrapper (that is not this script itself) was found, 1 otherwise.
set wrapper_script=
set this_script=%~f0
set project_dir=%CD%
:find_loop
set wrapper_candidate=%project_dir%\kotlin.bat
if "%this_script%"=="%wrapper_candidate%" (
@rem Found itself (local wrapper case), no need to update any version or search further.
exit /b 1
)
if exist "%wrapper_candidate%" (
@rem Found a wrapper — check that a project context exists alongside it
if exist "%project_dir%\project.yaml" (
set wrapper_script=%wrapper_candidate%
exit /b 0
)
if exist "%project_dir%\module.yaml" (
set wrapper_script=%wrapper_candidate%
exit /b 0
)
echo WARNING: Found wrapper script '%wrapper_candidate%', but no project.yaml or module.yaml near it. Skipping. >&2
@rem Continue the search
goto :find_next_parent
)
if exist "%project_dir%\project.yaml" (
@rem Found project.yaml but no wrapper alongside it
echo WARNING: Found a project.yaml in '%project_dir%', but the wrapper script is missing; using Kotlin Toolchain v%kotlin_cli_version%. >&2
exit /b 1
)
:find_next_parent
@rem Move to parent directory
for %%P in ("%project_dir%\..") do set parent_dir=%%~fP
if "%parent_dir%"=="%project_dir%" (
@rem Reached the root, stop searching
exit /b 1
)
set project_dir=%parent_dir%
goto :find_loop
:parse_project_context
@rem Parse kotlin_cli_version and kotlin_cli_sha256 from the found wrapper_script without executing it.
set parsed_kotlin_cli_version=
set parsed_kotlin_cli_sha256=
for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_version=[A-Za-z0-9._+-]*$" "%wrapper_script%"') do (
if not defined parsed_kotlin_cli_version set parsed_kotlin_cli_version=%%A
)
for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_sha256=[0-9a-fA-F]*$" "%wrapper_script%"') do (
if not defined parsed_kotlin_cli_sha256 set parsed_kotlin_cli_sha256=%%A
)
if not defined parsed_kotlin_cli_version (
echo ERROR: Suspicious local wrapper script: failed to detect the distribution version in '%wrapper_script%' >&2
exit /b 1
)
if not defined parsed_kotlin_cli_sha256 (
echo ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '%wrapper_script%' >&2
exit /b 1
)
@rem Overwrite builtin values and proceed
set kotlin_cli_version=%parsed_kotlin_cli_version%
set kotlin_cli_sha256=%parsed_kotlin_cli_sha256%
exit /b 0
:fail
echo ERROR: Kotlin CLI bootstrap failed, see errors above
exit /b 1
:after_function_declarations
REM ********** Project-local version detection **********
if defined KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION goto :after_local_version_detection
call :find_project_context
if errorlevel 1 goto :after_local_version_detection
call :parse_project_context
if errorlevel 1 goto fail
:after_local_version_detection
REM ********** Provision the Kotlin Toolchain distribution **********
set kotlin_cli_url=%KOTLIN_CLI_DOWNLOAD_ROOT%/org/jetbrains/kotlin/kotlin-cli/%kotlin_cli_version%/kotlin-cli-%kotlin_cli_version%-dist.tgz
set kotlin_cli_target_dir=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\kotlin-cli-%kotlin_cli_version%
call :download_and_extract "Kotlin Toolchain distribution v%kotlin_cli_version%" "%kotlin_cli_url%" "%kotlin_cli_target_dir%" "%kotlin_cli_sha256%" "256" "true"
if errorlevel 1 goto fail
REM ********** Launch the Kotlin CLI **********
rem Determine the correct busybox binary based on architecture
if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64a.exe
) else if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64u.exe
) else (
echo Unsupported architecture %PROCESSOR_ARCHITECTURE% >&2
goto fail
)
rem We use busybox here because it doesn't reinterpret the user-passed command-line arguments (that we pass via %*).
rem Also this way we can use the unified launcher script (.sh)
set KOTLIN_CLI_WRAPPER_PATH=%~f0
rem The '& call' after the app run is to avoid the "Terminate batch job (Y/N)?" prompt
"%busybox_exe%" sh "%kotlin_cli_target_dir%\bin\launcher.sh" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem We use "%COMSPEC%" /d /c exit so that and/or operators work properly when calling kotlin.bat directly from a script
@rem without the `call` command. With a plain `exit /B %ERRORLEVEL%`, using `kotlin.bat && echo success` would print
@rem 'success' even in case of failure. Consumers would have to use `call kotlin.bat && echo success` for it to work.`
"%COMSPEC%" /d /c exit %ERRORLEVEL%
+19
View File
@@ -0,0 +1,19 @@
[versions]
logback = "1.6.2"
androidx-activity-compose = "1.13.0"
androidx-lifecycle = "2.11.0"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization = "1.9.0"
ktor = "3.0.1"
[libraries]
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+4
View File
@@ -0,0 +1,4 @@
modules:
- androidApp
- desktopApp
- shared
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
+31
View File
@@ -0,0 +1,31 @@
product:
type: kmp/lib
platforms: [ jvm, android ]
dependencies:
- $compose.runtime
- $compose.foundation
- $compose.material3
- $compose.ui
- $compose.components.resources
- $compose.preview
- $compose.hotReload.runtimeApi
- $libs.androidx.lifecycle.viewmodel.compose
- $libs.androidx.lifecycle.runtime.compose
- $libs.ktor.client.core
- $libs.kotlinx.serialization.json
dependencies@jvm:
- $libs.ktor.client.cio
dependencies@android:
- $libs.ktor.client.okhttp
- $compose.uiTooling
settings:
compose:
enabled: true
resources:
packageName: com.hyperling.weather.resources
# Generate public accessors so other modules (and previews) can use Res.*
exposedAccessors: true
+61
View File
@@ -0,0 +1,61 @@
package com.hyperling.weather
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@Composable
fun App() {
// Initialize platform-specific storage and environment (JVM/Desktop vs Android)
val platformInit = remember { getPlatformInit() }
LaunchedEffect(platformInit) {
platformInit.initialize()
}
MaterialTheme {
MainScreen(modifier = Modifier.fillMaxSize())
}
}
@Composable
fun MainScreen(modifier: Modifier = Modifier) {
val viewModel = remember { MainViewModel() }
// Initialize ViewModel by loading existing locations
LaunchedEffect(Unit) {
viewModel.init()
}
Box(modifier = modifier) {
CelticWheel(
viewModel = viewModel,
onLocationClick = { loc ->
viewModel.startEditing(loc)
},
sizeInDp = 360
)
// If editing a location, show the Editor overlay
if (viewModel.editingLocation != null) {
LocationEditor(
entry = viewModel.editingLocation,
onDismiss = { viewModel.cancelEditing() },
onSave = { loc ->
viewModel.saveLocation(loc)
viewModel.cancelEditing()
viewModel.fetchAllWeather() // Refresh data after saving
}
)
}
// Loading indicator (optional visual cue)
if (viewModel.loading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
color = SeasonColors.Accent
)
}
}
}
+437
View File
@@ -0,0 +1,437 @@
package com.hyperling.weather
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import kotlin.math.cos
import kotlin.math.sin
/**
* The Celtic Season Wheel - 8 pie wedges representing the year's seasons.
* Each wedge shows weather data for a saved location.
*/
@Composable
fun CelticWheel(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
onLocationClick: (LocationEntry?) -> Unit,
onWeatherRefresh: () -> Unit = {},
sizeInDp: Int = 360,
) {
var centerX by remember { mutableStateOf(0f) }
var centerY by remember { mutableStateOf(0f) }
var radius by remember { mutableStateOf(0f) }
val wheelSizeDp = sizeInDp.dp
val hasData = viewModel.weatherData.isNotEmpty() && !viewModel.loading
Box(
modifier = modifier
.size(wheelSizeDp)
.onSizeChanged { size ->
centerX = size.width / 2f
centerY = size.height / 2f
radius = (size.minDimension / 2f) * 0.85f
}
) {
// Background circle
Box(
modifier = Modifier
.fillMaxSize()
.background(SeasonColors.Background.copy(alpha = 0.2f), CircleShape)
)
// Draw the wedges via canvas
CelticWheelCanvas(
centerX = centerX,
centerY = centerY,
radius = radius,
viewModel = viewModel,
)
// Hand pointer - positioned by current season angle
val handInfo = viewModel.getHandInfo()
Box(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp)
) {
HandPointer(angleDeg = Math.toDegrees(handInfo.angle).toFloat())
}
// Center info circle
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.align(Alignment.Center)
.size(72.dp)
.background(SeasonColors.CenterBg, CircleShape)
.clickable { if (hasData) onWeatherRefresh() }
) {
Text(
text = if (hasData) "Update" else "Add",
fontSize = 9.sp,
color = SeasonColors.TextSecondary
)
Text(
text = if (hasData) "Now" else "Locs",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = SeasonColors.TextPrimary
)
}
// Empty slot buttons around the wheel edge
for (i in viewModel.locations.size until Season.Segments) {
val angle = Season.angleFor(i)
val buttonX = centerX + radius * cos(angle) - 16
val buttonY = centerY + radius * sin(angle) - 16
IconButton(
x = buttonX.toInt(),
y = buttonY.toInt(),
sizeDp = 32,
icon = Icons.Default.Add,
tint = SeasonColors.Accent.copy(alpha = 0.5f),
onClick = { onLocationClick(null) }
)
}
// Weather data overlays for each filled slot
viewModel.locations.forEachIndexed { idx, location ->
val weather = viewModel.weatherData[location.id]
if (weather == null) return@forEachIndexed
val angle = Season.angleFor(idx)
val labelRadius = radius * 0.42f
val labelX = centerX + labelRadius * cos(angle) - 35
val labelY = centerY + labelRadius * sin(angle) - 12
// Temperature display
val highStr = weather.todayHighFahrenheit?.toInt()?.toString() ?: "?"
val lowStr = weather.todayLowFahrenheit?.toInt()?.toString() ?: "?"
LocationTempCard(
x = labelX.toInt(),
y = labelY.toInt(),
high = highStr,
low = lowStr,
seasonColor = Season.All[idx].color,
onDelete = { viewModel.deleteLocation(location.id) },
)
// Location name at outer ring
val nameRadius = radius * 0.68f
val nameAngle = Season.angleFor(idx)
val nameX = centerX + nameRadius * cos(nameAngle) - 35
val nameY = centerY + nameRadius * sin(angle) - 10
LocationLabel(
x = nameX.toInt(),
y = nameY.toInt(),
text = location.nickname,
)
}
}
}
/**
* Canvas-based wedge drawing for the wheel.
*/
@Composable
private fun CelticWheelCanvas(
centerX: Float,
centerY: Float,
radius: Float,
viewModel: MainViewModel,
) {
val sweepAngle = 360f / Season.Segments
androidx.compose.foundation.Canvas(
modifier = Modifier.fillMaxSize()
) {
val currentRadius = (size.minDimension / 2f) * 0.85f
val center = Offset(centerX, centerY)
// Draw wedges
for (i in Season.All.indices) {
val season = Season.All[i]
val hasData = i < viewModel.locations.size
&& viewModel.weatherData[viewModel.locations[i].id] != null
drawWedge(
center = center,
radius = currentRadius,
startAngleDeg = -90f + i * sweepAngle,
sweepDeg = sweepAngle,
color = if (hasData) season.color.copy(alpha = 0.6f)
else SeasonColors.WedgeEmpty.copy(alpha = 0.1f),
style = Fill
)
// Separator line between wedges
val sepAngle = -90f + (i + 1) * sweepAngle
val sepRad = Math.toRadians(sepAngle.toDouble())
drawLine(
color = SeasonColors.Divider.copy(alpha = if (hasData) 0.5f else 0.2f),
start = Offset(
centerX + 38f * cos(sepRad.toFloat()),
centerY + 38f * sin(sepRad.toFloat())
),
end = Offset(
centerX + (currentRadius - 2f) * cos(sepRad.toFloat()),
centerY + (currentRadius - 2f) * sin(sepRad.toFloat())
),
strokeWidth = 1.5f
)
}
// Inner ring
drawCircle(
color = SeasonColors.Divider.copy(alpha = 0.3f),
radius = currentRadius * 0.85f,
center = center,
style = Stroke(width = 1f)
)
// Outer ring
drawCircle(
color = SeasonColors.Divider.copy(alpha = 0.4f),
radius = currentRadius - 2f,
center = center,
style = Stroke(width = 2f)
)
// Center circle (behind text column)
drawCircle(
color = SeasonColors.CenterBg,
radius = 36f,
center = center
)
}
}
/**
* Draw a wedge path on canvas.
*/
private fun DrawScope.drawWedge(
center: Offset,
radius: Float,
startAngleDeg: Float,
sweepDeg: Float,
color: Color,
style: androidx.compose.ui.graphics.drawscope.DrawStyle,
) {
if (sweepDeg <= 0f || radius <= 0f) return
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(center.x, center.y)
// First point on arc (start of wedge)
val startRad = Math.toRadians(startAngleDeg.toDouble())
val startX = center.x + radius * cos(startRad.toFloat()).toFloat()
val startY = center.y + radius * sin(startRad.toFloat()).toFloat()
lineTo(startX, startY)
// Arc sweep to end point
arcTo(
androidx.compose.ui.geometry.Rect(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
),
startAngleDeg, sweepDeg, true
)
close()
}
drawPath(path, color, style)
}
/**
* Small icon button at absolute position.
*/
@Composable
private fun IconButton(
x: Int,
y: Int,
sizeDp: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
tint: Color = SeasonColors.TextPrimary,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.offset(x.dp, y.dp)
.size(sizeDp.dp)
.background(Color.Transparent, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(
icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(sizeDp * 0.6f)
)
}
}
/**
* Hand pointer showing current season position.
*/
@Composable
private fun HandPointer(
angleDeg: Float,
sizeDp: Int = 24,
) {
androidx.compose.foundation.Canvas(
modifier = Modifier.size(sizeDp.dp)
) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = size.width / 2f
val angleRad = Math.toRadians(angleDeg.toDouble())
// Pointer tip (points outward, away from center)
val tipX = cx + (r - 3) * cos(angleRad).toFloat()
val tipY = cy + (r - 3) * sin(angleRad).toFloat()
// Base points on circle
val baseAngle1 = angleRad + Math.PI / 7
val baseAngle2 = angleRad - Math.PI / 7
val base1X = cx + (r - 8) * cos(baseAngle1).toFloat()
val base1Y = cy + (r - 8) * sin(baseAngle1).toFloat()
val base2X = cx + (r - 8) * cos(baseAngle2).toFloat()
val base2Y = cy + (r - 8) * sin(baseAngle2).toFloat()
androidx.compose.ui.graphics.Path().apply {
moveTo(tipX, tipY)
lineTo(base1X, base1Y)
lineTo(base2X, base2Y)
close()
}.let { path ->
drawPath(path, color = Color.White.copy(alpha = 0.85f))
}
// Pivot dot
drawCircle(
color = SeasonColors.Accent,
radius = 4f,
center = Offset(cx, cy)
)
}
}
/**
* Temperature display card for a location on the wheel.
*/
@Composable
private fun LocationTempCard(
x: Int,
y: Int,
high: String,
low: String,
seasonColor: Color,
onDelete: () -> Unit,
) {
Box(
modifier = Modifier
.offset(x.dp, y.dp)
.size(72.dp, 28.dp)
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(4.dp))
.clickable(onClick = onDelete)
) {
// Color bar on left edge
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.width(3.dp)
.background(seasonColor.copy(alpha = 0.7f))
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).padding(horizontal = 4.dp)
) {
Row {
Text(high, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f))
Text("/", fontSize = 10.sp, color = SeasonColors.TextSecondary)
Text(low, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f))
}
Text(
text = "°F",
fontSize = 8.sp,
color = SeasonColors.TextSecondary
)
}
// Delete button (top right)
Box(
modifier = Modifier
.offset(52.dp, -2.dp)
.size(14.dp)
.background(Color.Red.copy(alpha = 0.3f), CircleShape)
.clickable(onClick = onDelete),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(10.dp)
)
}
}
}
/**
* Location name label at outer edge of wheel.
*/
@Composable
private fun LocationLabel(
x: Int,
y: Int,
text: String,
) {
Text(
text = text,
fontSize = 10.sp,
color = Color.White.copy(alpha = 0.85f),
fontWeight = FontWeight.Medium,
modifier = Modifier.offset(x.dp, y.dp)
)
}
+9
View File
@@ -0,0 +1,9 @@
package com.hyperling.weather
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
+140
View File
@@ -0,0 +1,140 @@
package com.hyperling.weather
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Dialog component for adding or editing a location.
*/
@Composable
fun LocationEditor(
entry: LocationEntry?,
onDismiss: () -> Unit,
onSave: (LocationEntry) -> Unit,
) {
var zip by androidx.compose.runtime.mutableStateOf(entry?.zipCode ?: "")
var lat by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.latitude != 0.0) entry.latitude.toString() else "")
var lon by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.longitude != 0.0) entry.longitude.toString() else "")
val isEdit = entry != null
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth(0.8f)
.background(Color.White, RoundedCornerShape(16.dp))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (isEdit) "Edit Location" else "Add Location",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = SeasonColors.TextPrimary
)
Spacer(modifier = Modifier.size(16.dp))
// Nickname / Zip Code input
TextField(
value = zip,
onValueChange = { zip = it },
label = { Text("Zip Code or Place Name") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("e.g. 90210 or London") }
)
Spacer(modifier = Modifier.size(8.dp))
// Latitude / Longitude (Advanced)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
TextField(
value = lat,
onValueChange = { lat = it },
label = { Text("Latitude") },
modifier = Modifier.weight(1f)
)
TextField(
value = lon,
onValueChange = { lon = it },
label = { Text("Longitude") },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.size(24.dp))
// Actions
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxWidth()
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Cancel, contentDescription = "Cancel", tint = SeasonColors.TextSecondary)
}
androidx.compose.material3.Button(
onClick = {
// Parse inputs to create/update LocationEntry
val latParsed = lat.toDoubleOrNull() ?: 0.0
val lonParsed = lon.toDoubleOrNull() ?: 0.0
onSave(
LocationEntry(
id = entry?.id ?: System.currentTimeMillis(),
zipCode = zip,
latitude = latParsed,
longitude = lonParsed,
nickname = zip, // Default nickname is the input string
lastUpdated = entry?.lastUpdated ?: 0L
)
)
},
enabled = zip.isNotBlank() || lat != "0.0" || lon != "0.0"
) {
Icon(Icons.Default.Check, contentDescription = "Save")
androidx.compose.material3.Text("Save", modifier = Modifier.padding(start = 4.dp))
}
}
}
// Backdrop / Overlay to dismiss
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable { onDismiss() }
) {}
}
}
+125
View File
@@ -0,0 +1,125 @@
package com.hyperling.weather
/**
* A saved location for weather lookup.
* Either zipCode OR (latitude + longitude) must be provided.
*/
data class LocationEntry(
val id: Long = System.currentTimeMillis(),
var zipCode: String = "",
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var nickname: String = "",
val lastUpdated: Long = 0L
) {
/**
* Whether this location has valid data to fetch weather for.
*/
val isValid: Boolean
get() = zipCode.isNotBlank() || (latitude != 0.0 && longitude != 0.0)
/**
* Display label for this location.
*/
val displayLabel: String
get() = nickname.takeIf { it.isNotBlank() }
?: zipCode.takeIf { it.isNotBlank() }
?: "Lat ${latitude.toString().take(5)}, Lon ${longitude.toString().take(5)}"
/**
* The coordinates to use for weather API lookup.
*/
fun getCoordinates(): Pair<Double, Double>? {
return if (zipCode.isNotBlank()) null else {
if (latitude != 0.0 && longitude != 0.0) Pair(latitude, longitude) else null
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is LocationEntry) return false
// Locations are equal if they share the same zip or coordinates
if (zipCode.isNotBlank() && other.zipCode.isNotBlank()) {
return zipCode.equals(other.zipCode, ignoreCase = true)
}
if (latitude != 0.0 || longitude != 0.0) {
return latitude == other.latitude && longitude == other.longitude
}
return id == other.id
}
override fun hashCode(): Int {
return when {
zipCode.isNotBlank() -> zipCode.lowercase().hashCode()
latitude != 0.0 || longitude != 0.0 -> latitude.hashCode() * 31 + longitude.hashCode()
else -> id.hashCode()
}
}
}
/**
* Weather data returned from OpenMeteo for a single day.
*/
data class DailyWeather(
val date: String,
val tempMaxCelsius: Double?,
val tempMinCelsius: Double?,
) {
val tempMaxFahrenheit: Double?
get() = tempMaxCelsius?.let { celsiusToFahrenheit(it) }
val tempMinFahrenheit: Double?
get() = tempMinCelsius?.let { celsiusToFahrenheit(it) }
companion object {
fun celsiusToFahrenheit(celsius: Double): Double {
return celsius * 9.0 / 5.0 + 32.0
}
}
}
/**
* Aggregated weather data for a location.
*/
data class LocationWeather(
val locationId: Long,
val zipCode: String,
val nickname: String,
val latitude: Double,
val longitude: Double,
val dailyWeather: List<DailyWeather>,
val lastUpdatedMillis: Long,
// Convenience properties for the "today" data (first in list)
val todayHighCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMaxCelsius,
val todayLowCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMinCelsius,
val todayHighFahrenheit: Double? get() = todayHighCelsius?.let { DailyWeather.celsiusToFahrenheit(it) },
val todayLowFahrenheit: Double? get() = todayLowCelsius?.let { DailyWeather.celsiusToFahrenheit(it) },
)
/**
* OpenMeteo API response for the daily forecast endpoint.
*/
data class OpenMeteoResponse(
val latitude: List<Double>,
val longitude: List<Any>, // HRES, etc. - we don't need this
val generationtime_ms: Double,
val utc_offset_seconds: Int,
val timezone: String,
val timezone_abbreviation: String,
val elevation: Double,
val daily: DailyResponse
)
data class DailyResponse(
val time: List<String>,
val weathercode: List<Int>?,
val temperature_2m_max: List<Double>?,
val temperature_2m_min: List<Double>?,
val apparent_temperature_max: List<Double>?,
val apparent_temperature_min: List<Double>?,
val sunrise: List<String>?,
val sunset: List<String>?,
val precipitation_sum: List<Double>?,
val wind_speed_10m_max: List<Double>?,
val shortwave_radiation_sum: List<Double>?,
)
+46
View File
@@ -0,0 +1,46 @@
package com.hyperling.weather
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* JSON serializer helper for location data.
*/
object LocationSerializer {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
@Serializable
private data class StoredLocation(
val id: Long,
val zipCode: String,
val latitude: Double,
val longitude: Double,
val nickname: String,
val lastUpdated: Long
)
fun locationsToJson(locations: List<LocationEntry>): String {
return json.encodeToString(
kotlinx.serialization.listSerializer(StoredLocation.serializer()),
locations.map { locationToStored(it) }
)
}
fun jsonToLocations(jsonStr: String): List<LocationEntry> {
return try {
json.decodeFromString<List<StoredLocation>>(jsonStr).map { storedToLocation(it) }
} catch (e: Exception) {
emptyList()
}
}
private fun locationToStored(loc: LocationEntry): StoredLocation =
StoredLocation(loc.id, loc.zipCode, loc.latitude, loc.longitude, loc.nickname, loc.lastUpdated)
private fun storedToLocation(stored: StoredLocation): LocationEntry =
LocationEntry(stored.id, stored.zipCode, stored.latitude, stored.longitude, stored.nickname, stored.lastUpdated)
}
+37
View File
@@ -0,0 +1,37 @@
package com.hyperling.weather
/**
* Platform-agnostic location storage abstraction.
*/
object LocationStorage {
/**
* Load all saved locations.
*/
fun load(): List<LocationEntry> = getStorage().load()
/**
* Save all locations (replaces existing).
*/
fun save(locations: List<LocationEntry>) = getStorage().save(locations)
/**
* Save a single location, merging with existing list.
*/
fun saveOne(location: LocationEntry) = getStorage().saveOne(location)
/**
* Delete a location by id.
*/
fun delete(id: Long) = getStorage().delete(id)
expect fun getStorage(): StorageBackend
}
interface StorageBackend {
fun load(): List<LocationEntry>
fun save(locations: List<LocationEntry>)
fun saveOne(location: LocationEntry)
fun delete(id: Long)
}
expect fun getPlatformStorage(): StorageBackend
+195
View File
@@ -0,0 +1,195 @@
package com.hyperling.weather
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* ViewModel for the main screen. Manages locations, weather data, and UI state.
*/
class MainViewModel : ViewModel() {
var loading by mutableStateOf(false)
private set
var error: String? by mutableStateOf(null)
private set
// Up to 8 locations in wheel order
var locations by mutableStateOf<List<LocationEntry>>(emptyList())
private set
// Weather data keyed by location id
val weatherData = mutableStateMapOf<Long, LocationWeather>()
// Currently editing location (null means create new)
var editingLocation: LocationEntry? by mutableStateOf(null)
private set
fun initialize() {
viewModelScope.launch {
locations = LocationStorage.load()
if (locations.isNotEmpty()) {
fetchAllWeather()
}
}
}
/**
* Add or update a location entry.
*/
fun saveLocation(location: LocationEntry) {
val validated = validateLocation(location)
if (!validated.second) {
error = validated.first
return
}
viewModelScope.launch {
LocationStorage.saveOne(validated.third)
locations = LocationStorage.load()
// Immediately fetch weather for new/updated location
fetchWeatherFor(validated.third)
}
}
/**
* Delete a location.
*/
fun deleteLocation(id: Long) {
viewModelScope.launch {
LocationStorage.delete(id)
locations = LocationStorage.load()
weatherData.remove(id)
}
}
/**
* Start editing a location (for the dialog).
*/
fun startEditing(location: LocationEntry?) {
editingLocation = location?.copy() ?: LocationEntry(
id = System.currentTimeMillis(),
nickname = ""
)
}
/**
* Clear the current edit session.
*/
fun cancelEditing() {
editingLocation = null
}
/**
* Fetch weather for all saved locations.
*/
suspend fun fetchAllWeather() {
loading = true
error = null
try {
WeatherRepository.init(getEngine())
val results = withContext(Dispatchers.IO) {
locations.map { loc ->
loc to WeatherRepository.getWeatherForLocation(loc)
}
}
for ((loc, result) in results) {
if (result.isSuccess) {
weatherData[loc.id] = result.getOrThrow()
} else {
error = "Failed to fetch weather for ${loc.displayLabel}: ${result.exceptionOrNull()?.message}"
}
}
} catch (e: Exception) {
error = "Network error: ${e.message}"
} finally {
loading = false
}
}
/**
* Fetch weather for a single location.
*/
private suspend fun fetchWeatherFor(location: LocationEntry) {
WeatherRepository.init(getEngine())
val result = withContext(Dispatchers.IO) {
WeatherRepository.getWeatherForLocation(location)
}
if (result.isSuccess) {
weatherData[location.id] = result.getOrThrow()
} else {
error = "Failed to fetch weather: ${result.exceptionOrNull()?.message}"
}
}
/**
* Validate a location entry. Returns (errorMessage?, validatedLocation).
*/
private fun validateLocation(input: LocationEntry): Triple<String?, LocationEntry, Boolean> {
val hasZip = input.zipCode.isNotBlank()
val hasCoords = input.latitude != 0.0 && input.longitude != 0.0
if (!hasZip && !hasCoords) {
return Triple("Either a zip code or latitude/longitude is required.", input, false)
}
// If only zip provided, validate it's numeric
if (hasZip && !hasCoords) {
val cleanZip = input.zipCode.trim().replace(NonDigitRegex, "")
if (cleanZip.length != 5) {
return Triple("Please enter a valid zip code.", input.copy(zipCode = cleanZip), false)
}
}
// If coords provided but no zip, generate a placeholder
val validated = if (hasCoords && !hasZip) {
input.copy(zipCode = "")
} else {
input.copy(zipCode = input.zipCode.trim())
}
return Triple(null, validated, true)
}
/**
* Get weather data for a specific location wedge.
*/
fun getWeatherForLocation(idx: Int): LocationWeather? {
return if (idx < locations.size) {
weatherData[locations[idx].id]
} else null
}
/**
* Get the hand info for the current date/season position.
*/
fun getHandInfo(): HandInfo {
val calendar = java.util.Calendar.getInstance()
return HandInfo(
dayOfYear = calendar.get(java.util.Calendar.DAY_OF_YEAR),
totalDaysInYear = if (calendar.isLeapYear(calendar.get(java.util.Calendar.YEAR))) 366 else 365,
fractionThroughSeason = Season.handAngle(),
currentSeason = Season.current()
)
}
companion object {
private val NonDigitRegex = "[^0-9]".toRegex()
}
}
// Platform-specific engine provider
internal expect fun getEngine(): io.ktor.client.engine.HttpClientEngine
+7
View File
@@ -0,0 +1,7 @@
package com.hyperling.weather
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
+11
View File
@@ -0,0 +1,11 @@
package com.hyperling.weather
/**
* Platform-specific initialization interface.
* Used to set up storage backends or other environment needs based on the platform.
*/
interface PlatformInit {
fun initialize()
}
expect fun getPlatformInit(): PlatformInit
+166
View File
@@ -0,0 +1,166 @@
package com.hyperling.weather
import androidx.compose.ui.graphics.Color
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* The 8 positions on the Celtic Season Wheel.
* Based on solar events arranged clockwise starting from Summer at top (12 o'clock).
*/
enum class Season(
val label: String,
val fullName: String,
val color: Color,
/**
* Start of season expressed as month (1-12) and day (1-31).
* Used for the hand pointer position.
*/
val startMonth: Int,
val startDay: Int,
) {
// Summer solstice area - top of wheel (12 o'clock = 0 degrees in Compose arc coords)
SUMMER1("V1", "Summer Cross Quarter to Fall Equinox", Color(0xFFFBBC04), 6, 21),
SUMMER2("V2", "Fall Equinox to Winter Solstice (early)", Color(0xFFFF8F00), 8, 1),
FALL1("F1", "Fall Equinox to Fall Cross Quarter", Color(0xFFE65100), 9, 21),
FALL2("F2", "Winter Solstice to Winter Cross Quarter (early)", Color(0xFF6D4C41), 11, 1),
WINTER1("W1", "Winter Cross Quarter to Spring Equinox (late)", Color(0xFF546E7A), 12, 21),
WINTER2("W2", "Spring Equinox to Summer Solstice (early)", Color(0xFF37474F), 2, 1),
SPRING1("S1", "Summer Cross Quarter to Fall Equinox", Color(0xFF4CAF50), 3, 21),
SPRING2("S2", "Cross Quarter to Summer Solstice", Color(0xFF81C784), 5, 1);
companion object {
val All = values()
val Segments = All.size // 8
/**
* Get the current season based on today's date.
*/
fun current(): Season {
val calendar = java.util.Calendar.getInstance()
val month = calendar.get(java.util.Calendar.MONTH) + 1 // 1-12
val day = calendar.get(java.util.Calendar.DAY_OF_MONTH)
return All.find { season ->
val startStart = season.startMonth * 100 + season.startDay
// Calculate end as next season's start
val idx = season.ordinal
val nextSeason = if (idx < All.size - 1) All[idx + 1] else All[0]
val endStart = nextSeason.startMonth * 100 + nextSeason.startDay
val currentPos = month * 100 + day
when {
startStart <= endStart ->
currentPos in startStart..endStart
else ->
currentPos >= startStart || currentPos <= endStart
}
} ?: SPRING1
}
/**
* Get the hand angle (in radians) for the current date.
* 0 radians = top of wheel (Summer Solstice), increasing clockwise.
*/
fun handAngle(): Double {
val calendar = java.util.Calendar.getInstance()
val month = calendar.get(java.util.Calendar.MONTH) + 1
val day = calendar.get(java.util.Calendar.DAY_OF_MONTH)
// Calculate which season we're in and how far through it
val currentSeason = current()
val idx = currentSeason.ordinal
val nextIdx = (idx + 1) % All.size
val nextSeason = All[nextIdx]
val startPos = currentSeason.startMonth * 100 + currentSeason.startDay
val endPos = nextSeason.startMonth * 100 + nextSeason.startDay
// Handle year wrap for seasons that cross January
var totalDays = if (endPos > startPos) {
daysBetween(currentSeason.startMonth, currentSeason.startDay,
nextSeason.startMonth, nextSeason.startDay)
} else {
// Crosses year boundary
val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, currentSeason.startDay)
val daysRemaining = daysInCurrentMonth - currentSeason.startDay + 1
val daysFromStartNext = nextSeason.startMonth * 30 + nextSeason.startDay - 15
daysRemaining + daysFromStartNext
}
if (totalDays <= 0) totalDays = 90 // fallback
val currentPos = month * 100 + day
var elapsedDays: Int
if (endPos > startPos) {
elapsedDays = daysBetween(currentSeason.startMonth, currentSeason.startDay, month, day)
} else {
val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, 1)
elapsedDays = (daysInCurrentMonth - currentSeason.startDay + 1) + (day - 1)
}
if (elapsedDays < 0) elapsedDays = 0
val fraction = elapsedDays.toDouble() / totalDays.coerceAtLeast(1)
// Map to angle: 0 at top (Summer Solstice), clockwise
return fraction * 2 * PI
}
private fun daysInMonth(month: Int, day: Int): Int {
val cal = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.MONTH, month - 1)
}
return cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH)
}
private fun daysBetween(m1: Int, d1: Int, m2: Int, d2: Int): Int {
val start = java.util.GregorianCalendar(2024, m1 - 1, d1).timeInMillis
val end = java.util.GregorianCalendar(2024, m2 - 1, d2).timeInMillis
return ((end - start) / (1000 * 60 * 60 * 24)).toInt()
}
/**
* Get the angle for a season's center on the wheel.
* 0 radians = top of wheel, increasing clockwise.
*/
fun angleFor(idx: Int): Double {
return (idx.toDouble() / Segments) * 2 * PI
}
/**
* Get the sweep angle for each wedge (360 degrees / 8 = 45 degrees).
*/
val wedgeSweep: Double
get() = 2 * PI / Segments
}
}
/**
* Color palette for the Celtic Wheel UI.
*/
object SeasonColors {
val Background = Color(0xFF1A1A2E)
val CenterBg = Color(0xFF16213E)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB0BEC5)
val Divider = Color(0xFF37474F)
val Shadow = Color(0x4D000000)
}
/**
* Current date/time info for the wheel hand.
*/
data class HandInfo(
val dayOfYear: Int,
val totalDaysInYear: Int,
val fractionThroughSeason: Double,
val currentSeason: Season
) {
val angle: Double
get() = (dayOfYear.toDouble() / totalDaysInYear) * 2 * Math.PI
}
+97
View File
@@ -0,0 +1,97 @@
package com.hyperling.weather
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.encodeURLParameter
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Repository for fetching weather data from OpenMeteo API.
* OpenMeteo is free and does not require an API key for basic usage.
*/
object WeatherRepository {
private const val OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = false
}
private lateinit var httpClient: HttpClient
fun init(engine: HttpClientEngine) {
httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(json)
}
}
}
/**
* Fetch weather data for a location by coordinates.
* Returns the parsed OpenMeteo daily forecast response.
*/
suspend fun fetchWeather(latitude: Double, longitude: Double): OpenMeteoResponse {
return httpClient.get(OPEN_METEO_URL) {
url {
parameters.append("latitude", latitude.toString())
parameters.append("longitude", longitude.toString())
parameters.append("daily", "temperature_2m_max,temperature_2m_min")
parameters.append("forecast_days", "7")
parameters.append("timezone", "auto")
}
}.body()
}
/**
* Parse the raw API response into our domain models.
*/
fun parseDailyWeather(response: OpenMeteoResponse): List<DailyWeather> {
val dates = response.daily.time ?: emptyList()
val highs = response.daily.temperature_2m_max ?: emptyList()
val lows = response.daily.temperature_2m_min ?: emptyList()
return dates.mapIndexed { index, date ->
DailyWeather(
date = date,
tempMaxCelsius = if (index < highs.size) highs[index] else null,
tempMinCelsius = if (index < lows.size) lows[index] else null,
)
}
}
/**
* Convert a location entry to weather data.
*/
suspend fun getWeatherForLocation(location: LocationEntry): Result<LocationWeather> {
return try {
val response = if (location.zipCode.isNotBlank()) {
// OpenMeteo doesn't support zip code lookup directly,
// but we can use the lat/long which should be populated
fetchWeather(location.latitude, location.longitude)
} else {
fetchWeather(location.latitude, location.longitude)
}
val daily = parseDailyWeather(response)
Result.success(
LocationWeather(
locationId = location.id,
zipCode = location.zipCode,
nickname = location.nickname,
latitude = location.latitude,
longitude = location.longitude,
dailyWeather = daily,
lastUpdatedMillis = System.currentTimeMillis(),
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,50 @@
package com.hyperling.weather
import android.content.Context
/**
* SharedPreferences-based local storage manager for location entries (Android).
*/
class AndroidStorageBackend(private val context: Context) : StorageBackend {
private companion object {
const val PREFS_NAME = "nomadic_weather_prefs"
const val KEY_LOCATIONS = "locations_json"
}
private val prefs by lazy {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
override fun load(): List<LocationEntry> {
val jsonStr = prefs.getString(KEY_LOCATIONS, null) ?: return emptyList()
if (jsonStr.isEmpty()) return emptyList()
return try {
LocationSerializer.jsonToLocations(jsonStr)
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
prefs.edit().putString(KEY_LOCATIONS, LocationSerializer.locationsToJson(locations)).apply()
}
override fun saveOne(location: LocationEntry) {
val existing = load().toMutableList()
val idx = existing.indexOfFirst { it == location }
if (idx >= 0) existing[idx] = location
else {
if (existing.size >= 8) existing.removeAt(0)
existing.add(location)
}
save(existing)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
actual fun LocationStorage.getStorage(): StorageBackend =
throw UnsupportedOperationException("getStorage requires AndroidContext - this is a desktop build")
@@ -0,0 +1,6 @@
package com.hyperling.weather
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.engine.HttpClientEngine
actual fun getEngine(): HttpClientEngine = OkHttp.create()
@@ -0,0 +1,9 @@
package com.hyperling.weather
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()
+49
View File
@@ -0,0 +1,49 @@
package com.hyperling.weather
import java.io.File
/**
* File-based local storage manager for location entries (JVM/Desktop).
*/
class JvmStorageBackend : StorageBackend {
private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather")
private val STORAGE_FILE = File(STORAGE_DIR, "locations.json")
init {
STORAGE_DIR.mkdirs()
}
override fun load(): List<LocationEntry> {
return try {
if (STORAGE_FILE.exists()) {
LocationSerializer.jsonToLocations(STORAGE_FILE.readText())
} else emptyList()
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
try {
STORAGE_DIR.mkdirs()
STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations))
} catch (e: Exception) {}
}
override fun saveOne(location: LocationEntry) {
val existing = load().toMutableList()
val idx = existing.indexOfFirst { it == location }
if (idx >= 0) existing[idx] = location
else {
if (existing.size >= 8) existing.removeAt(0)
existing.add(location)
}
save(existing)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
actual fun LocationStorage.getStorage(): StorageBackend = JvmStorageBackend()
+6
View File
@@ -0,0 +1,6 @@
package com.hyperling.weather
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.HttpClientEngine
actual fun getEngine(): HttpClientEngine = CIO.create()
+7
View File
@@ -0,0 +1,7 @@
package com.hyperling.weather
class JVMPlatform: Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
actual fun getPlatform(): Platform = JVMPlatform()
+59
View File
@@ -0,0 +1,59 @@
package com.hyperling.weather
import java.io.File
/**
* JVM Implementation of StorageBackend for Desktop use.
*/
class JvmStorageBackendImpl : StorageBackend {
private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather")
private val STORAGE_FILE = File(STORAGE_DIR, "locations.json")
init {
STORAGE_DIR.mkdirs()
}
override fun load(): List<LocationEntry> {
return try {
if (STORAGE_FILE.exists()) LocationSerializer.jsonToLocations(STORAGE_FILE.readText()) else emptyList()
} catch (e: Exception) {
emptyList()
}
}
override fun save(locations: List<LocationEntry>) {
try {
STORAGE_DIR.mkdirs()
STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations))
} catch (e: Exception) {}
}
override fun saveOne(location: LocationEntry) {
val ex = load().toMutableList()
val idx = ex.indexOfFirst { it == location }
if (idx >= 0) ex[idx] = location
else {
if (ex.size >= 8) ex.removeAt(0)
ex.add(location)
}
save(ex)
}
override fun delete(id: Long) {
save(load().filter { it.id != id })
}
}
/**
* JVM-specific platform initialization.
*/
class JvmPlatformInit : PlatformInit {
override fun initialize() {
val dir = File(System.getProperty("user.home"), ".nomadic-weather")
if (!dir.exists()) dir.mkdirs()
println("Nomadic Weather Desktop Initialized")
}
}
actual fun getPlatformStorage(): StorageBackend = JvmStorageBackendImpl()
actual fun getPlatformInit(): PlatformInit = JvmPlatformInit()
+17
View File
@@ -0,0 +1,17 @@
package com.hyperling.weather
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PlatformTest {
@Test
fun `platform name is reported`() {
assertTrue(getPlatform().name.isNotBlank())
}
@Test
fun `greeting is built from the platform name`() {
assertEquals("Hello, ${getPlatform().name}!", Greeting().greet())
}
}
@@ -0,0 +1,11 @@
package com.hyperling.weather
import kotlin.test.Test
import kotlin.test.assertTrue
class PlatformAndroidTest {
@Test
fun `platform name is reported`() {
assertTrue(getPlatform().name.isNotBlank())
}
}
+11
View File
@@ -0,0 +1,11 @@
package com.hyperling.weather
import kotlin.test.Test
import kotlin.test.assertTrue
class PlatformJvmTest {
@Test
fun `platform name is reported`() {
assertTrue(getPlatform().name.isNotBlank())
}
}
View File
Whitespace-only changes.