Built and ran as an iOS homescreen widget using the Scriptable app.
This widget takes an OpenWeatherMap API key to provide hourly, daily, and sunset/sunrise weather info. In addition, one section displays upcoming events from the Calendar app.
Built and ran as an iOS homescreen widget using the Scriptable app.
This widget takes an OpenWeatherMap API key to provide hourly, daily, and sunset/sunrise weather info. In addition, one section displays upcoming events from the Calendar app.
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: green; icon-glyph: magic; | |
// this Scriptable Widget is coded by Slowlydev (aka r/Sl0wly-edits, r/Slowlydev) | |
// and adapted by @marco79 and @grantmavery. Originally named "Date & Agenda & Weather" | |
// https://gist.github.com/marco79cgn/fa9cd9a3423be4500a20a54cb783f4c0 | |
////// CONSTANTS | |
const DEV_MODE = false //for developer only | |
const DEV_PREVIEW = "medium" //for developer only (this script is specialy made for a medium sized widget) | |
// !!NEW USER INPUT REQUIRED!! | |
const API_KEY = "" // enter your openweathermap.com api key | |
const FORECAST_HOURS = "3" | |
const FORECAST_DAYS = "3" | |
const UNITS = "imperial" //metric for celsius and imperial for Fahrenheit | |
const CALENDAR_URL = "calshow://" //Apple Calendar App, if your favorite app does have a URL scheme feel free to change it | |
const WEATHER_URL = "weatherline://" //there is no URL for the Apple Weather App, if your favorite app does feel free to add it | |
const widgetBackground = new Color("#D6D6D6") //Widget Background | |
const stackBackground = new Color("#FFFFFF") //Smaller Container Background | |
const calendarColor = new Color("#EA3323") //Calendar Color | |
const stackSize = new Size(0, 65) //0 means it's automatic | |
// !!NEW USER INPUT REQUIRED!! | |
// store calendar colors using the name as shown in the Calendar app | |
colors = { | |
"Cal1": Color.blue(), | |
"Cal2": Color.purple(), | |
"Cal3": Color.yellow(), | |
"Cal4": Color.white(), | |
"Cal5": Color.green() | |
} | |
// set to true if you want to hide all-day events | |
hideAllDay = false | |
////// CREATE WIDGET | |
if (config.runsInWidget || DEV_MODE) { | |
const date = new Date() | |
const dateNow = Date.now() | |
let df_Name = new DateFormatter() | |
let df_Month = new DateFormatter() | |
df_Name.dateFormat = "EEEE" | |
df_Month.dateFormat = "MMMM" | |
const dayName = df_Name.string(date) | |
const dayNumber = date.getDate().toString() | |
const monthName = df_Month.string(date) | |
// Option 1: uncomment this to use let the script locate you each time (which takes longer and needs more battery) | |
let loc = await Location.current() | |
let lat = loc["latitude"] | |
let lon = loc["longitude"] | |
// Option 2: hard coded longitude/latitude | |
// let lat = "" | |
// let lon = "" | |
const weatherURL = `https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lon}&exclude=current,minutely,alerts&units=${UNITS}&appid=${API_KEY}` | |
const weatherRequest = new Request(weatherURL) | |
const weatherData = await weatherRequest.loadJSON() | |
const hourlyForecasts = weatherData.hourly | |
let hourlyNextForecasts = [] | |
for (const hourlyForecast of hourlyForecasts) { | |
if (hourlyNextForecasts.length == FORECAST_HOURS) { break } | |
let dt = removeDigits(dateNow, 3) | |
if (hourlyForecast.dt >= dt) { | |
hourlyNextForecasts.push(hourlyForecast) | |
} | |
} | |
const dailyForecasts = weatherData.daily | |
let dailyNextForecasts = [] | |
for (const dailyForecast of dailyForecasts) { | |
if (dailyNextForecasts.length == FORECAST_DAYS) { break } | |
let dt = removeDigits(dateNow, 3) | |
if (dailyForecast.dt >= dt) { | |
dailyNextForecasts.push(dailyForecast) | |
} | |
} | |
// Find future events that aren't all day and aren't canceled | |
const events = await CalendarEvent.today([]) | |
let futureEvents = [] | |
for (const event of events) { | |
if (futureEvents.length == 2) { break } | |
if (hideAllDay) { | |
if (event.startDate.getTime() >= date.getTime() && !event.isAllDay && !event.title.startsWith("(Canceled)") && (event.calendar.title in colors)) { | |
futureEvents.push(event) | |
} | |
} | |
else { | |
if ((event.calendar.title in colors) | |
&& (event.isAllDay | |
|| (event.startDate.getTime() >= date.getTime() && !event.title.startsWith("(Canceled)")))) { | |
futureEvents.push(event) | |
} | |
} | |
} | |
let widget = new ListWidget() | |
widget.backgroundColor = widgetBackground | |
widget.setPadding(5, 5, 5, 5) | |
//// Top Row (Hourly and Daily Weather) | |
let topRow = widget.addStack() | |
topRow.layoutHorizontally() | |
widget.addSpacer() | |
// Top Row Hourly Weather | |
let hourlyWeatherStack = topRow.addStack() | |
hourlyWeatherStack.layoutHorizontally() | |
hourlyWeatherStack.centerAlignContent() | |
hourlyWeatherStack.setPadding(7, 7, 7, 7) | |
hourlyWeatherStack.backgroundColor = stackBackground | |
hourlyWeatherStack.cornerRadius = 12 | |
hourlyWeatherStack.size = stackSize | |
hourlyWeatherStack.url = WEATHER_URL | |
for (const nextForecast of hourlyNextForecasts) { | |
const iconURL = "https://openweathermap.org/img/wn/" + nextForecast.weather[0].icon + "@2x.png" | |
let iconRequest = new Request(iconURL); | |
let icon = await iconRequest.loadImage(); | |
hourlyWeatherStack.addSpacer() | |
let hourStack = hourlyWeatherStack.addStack() | |
hourStack.layoutVertically() | |
let hourTxt = hourStack.addText(formatHours(nextForecast.dt)) | |
hourTxt.centerAlignText() | |
hourTxt.font = Font.systemFont(10) | |
hourTxt.textColor = Color.black() | |
hourTxt.textOpacity = 0.5 | |
let weatherIcon = hourStack.addImage(icon) | |
weatherIcon.centerAlignImage() | |
weatherIcon.size = new Size(25, 25) | |
let tempTxt = hourStack.addText(" " + Math.round(nextForecast.temp) + "°") | |
tempTxt.centerAlignText() | |
tempTxt.font = Font.systemFont(10) | |
tempTxt.textColor = Color.black() | |
} | |
hourlyWeatherStack.addSpacer() | |
topRow.addSpacer() | |
// Top Row Daily Weather | |
let dailyWeatherStack = topRow.addStack() | |
dailyWeatherStack.layoutHorizontally() | |
dailyWeatherStack.centerAlignContent() | |
dailyWeatherStack.setPadding(7, 2, 7, 2) | |
dailyWeatherStack.backgroundColor = stackBackground | |
dailyWeatherStack.cornerRadius = 12 | |
dailyWeatherStack.size = stackSize | |
dailyWeatherStack.url = WEATHER_URL | |
for (const nextForecast of dailyNextForecasts) { | |
const iconURL = "https://openweathermap.org/img/wn/" + nextForecast.weather[0].icon + "@2x.png" | |
let iconRequest = new Request(iconURL); | |
let icon = await iconRequest.loadImage(); | |
dailyWeatherStack.addSpacer() | |
let dayStack = dailyWeatherStack.addStack() | |
dayStack.layoutVertically() | |
let hourTxt = dayStack.addText(formatDay(nextForecast.dt)) | |
hourTxt.centerAlignText() | |
hourTxt.font = Font.systemFont(10) | |
hourTxt.textColor = Color.black() | |
hourTxt.textOpacity = 0.5 | |
let weatherIcon = dayStack.addImage(icon) | |
weatherIcon.centerAlignImage() | |
weatherIcon.size = new Size(25, 25) | |
let tempTxt = dayStack.addText( | |
"" + Math.round(nextForecast.temp.max) + | |
"-" + Math.round(nextForecast.temp.min) + "°") | |
tempTxt.centerAlignText() | |
tempTxt.font = Font.systemFont(10) | |
tempTxt.textColor = Color.black() | |
} | |
dailyWeatherStack.addSpacer() | |
//// Bottom Row (Events and Sunrise/set) | |
let bottomRow = widget.addStack() | |
bottomRow.layoutHorizontally() | |
// Bottom Row Events | |
let eventStack = bottomRow.addStack() | |
eventStack.layoutHorizontally() | |
eventStack.centerAlignContent() | |
eventStack.setPadding(7, 2, 7, 2) | |
eventStack.backgroundColor = stackBackground | |
eventStack.cornerRadius = 12 | |
eventStack.size = stackSize | |
let eventInfoStack | |
const font = Font.lightSystemFont(20) | |
let calendarSymbol = SFSymbol.named("calendar") | |
calendarSymbol.applyFont(font) | |
eventStack.addSpacer(8) | |
let eventIcon = eventStack.addImage(calendarSymbol.image) | |
eventIcon.imageSize = new Size(20, 20) | |
eventIcon.resizable = false | |
eventIcon.centerAlignImage() | |
eventStack.addSpacer(14) | |
eventStack.url = CALENDAR_URL | |
let eventItemsStack = eventStack.addStack() | |
eventItemsStack.layoutVertically() | |
if (futureEvents.length != 0) { | |
for (let i = 0; i < futureEvents.length; i++) { | |
let futureEvent = futureEvents[i] | |
const time = formatTime(futureEvent.startDate) + "-" + formatTime(futureEvent.endDate) | |
const eventColor = new Color("#" + futureEvent.calendar.color.hex) | |
eventInfoStack = eventItemsStack.addStack() | |
eventInfoStack.layoutVertically() | |
let eventTitle = eventItemsStack.addText(futureEvent.title) | |
eventTitle.font = Font.semiboldSystemFont(12) | |
eventTitle.textColor = eventColor | |
eventTitle.lineLimit = 1 | |
let eventTime = eventItemsStack.addText(time) | |
eventTime.font = Font.semiboldMonospacedSystemFont(10) | |
eventTime.textColor = Color.black() | |
eventTime.textOpacity = 0.5 | |
if (i == 0) { | |
eventItemsStack.addSpacer(3) | |
} | |
} | |
} else { | |
let nothingText = eventStack.addText("You have no upcoming events!") | |
nothingText.font = Font.semiboldMonospacedSystemFont(12) | |
nothingText.textColor = Color.black() | |
nothingText.textOpacity = 0.5 | |
} | |
eventStack.addSpacer() | |
bottomRow.addSpacer() | |
// Bottom Row Sunrise/set | |
let sunStack = bottomRow.addStack() | |
sunStack.layoutHorizontally() | |
sunStack.centerAlignContent() | |
sunStack.setPadding(7, 7, 7, 7) | |
sunStack.url = WEATHER_URL | |
sunStack.backgroundColor = stackBackground | |
sunStack.cornerRadius = 12 | |
sunStack.size = stackSize | |
createSymbolStack(sunStack, dailyNextForecasts[0].sunrise, "sunrise.fill") | |
createSymbolStack(sunStack, dailyNextForecasts[0].sunset, "sunset.fill") | |
sunStack.addSpacer() | |
Script.setWidget(widget) | |
if (DEV_MODE) { | |
if (DEV_PREVIEW == "small") { widget.presentSmall() } | |
if (DEV_PREVIEW == "medium") { widget.presentMedium() } | |
if (DEV_PREVIEW == "large") { widget.presentLarge() } | |
} | |
Script.complete() | |
} | |
////// FUNCTIONS | |
function removeDigits(x, n) { return (x - (x % Math.pow(10, n))) / Math.pow(10, n) } | |
function formatHours(UNIX_timestamp) { | |
var date = new Date(UNIX_timestamp * 1000) | |
var hours = date.getHours() | |
var ampm = hours >= 12 ? ' PM' : ' AM' | |
hours = hours % 12 | |
hours = hours ? hours : 12 | |
var strTime = hours.toString() + ampm | |
return strTime | |
} | |
function formatHoursMin(UNIX_timestamp) { | |
var date = new Date(UNIX_timestamp * 1000) | |
var hours = date.getHours() | |
var minutes = "0" + date.getMinutes(); | |
var ampm = hours >= 12 ? ' PM' : ' AM' | |
hours = hours % 12 | |
hours = hours ? hours : 12 | |
var strTime = hours.toString() + ":" + minutes.substr(-2) + ampm | |
return strTime | |
} | |
function formatDay(UNIX_timestamp) { | |
var date = new Date(UNIX_timestamp * 1000) | |
var day = date.getDay() | |
var dayStr = "" | |
switch (day) { | |
case (0): | |
dayStr = " Sun" | |
break | |
case (1): | |
dayStr = " Mon" | |
break | |
case (2): | |
dayStr = " Tue" | |
break | |
case (3): | |
dayStr = " Wed" | |
break | |
case (4): | |
dayStr = " Thu" | |
break | |
case (5): | |
dayStr = " Fri" | |
break | |
case (6): | |
dayStr = " Sat" | |
break | |
} | |
return dayStr | |
} | |
function formatTime(date) { | |
let df = new DateFormatter() | |
df.useNoDateStyle() | |
df.useShortTimeStyle() | |
return df.string(date) | |
} | |
async function getImg(image) { | |
let fm = FileManager.iCloud() | |
let dir = fm.documentsDirectory() | |
let path = fm.joinPath(dir + "/imgs/weather", image) | |
let download = await fm.downloadFileFromiCloud(path) | |
let isDownloaded = await fm.isFileDownloaded(path) | |
if (fm.fileExists(path)) { | |
return fm.readImage(path) | |
} else { | |
console.log("Error: File does not exist.") | |
} | |
} | |
function createSymbolStack(sunStack, UNIX_timestamp, symbolName) { | |
sunStack.addSpacer() | |
let sunsetStack = sunStack.addStack() | |
sunsetStack.layoutVertically() | |
let sunsetTxt = sunsetStack.addText(formatHoursMin(UNIX_timestamp)) | |
sunsetTxt.centerAlignText() | |
sunsetTxt.font = Font.systemFont(10) | |
sunsetTxt.textColor = Color.black() | |
let sunsetSymbol = SFSymbol.named(symbolName) | |
let sunsetIcon = sunsetStack.addImage(sunsetSymbol.image) | |
sunsetIcon.tintColor = Color.black() | |
sunsetIcon.centerAlignImage() | |
sunsetIcon.imageSize = new Size(30, 30) | |
} |
Could you be more specific? Have you created an OpenWeatherMap API account and filled in the two "NEW USER INPUT REQUIRED" pieces in the code?
Error on line 91:43
Alright, again the answers to my other questions would be helpful, but from that line number I'd guess your device is having an issue retrieving calendar events. Do you have the Apple Calendar app installed? Do you have events on your calendar for today?
Apple Calendar and active Events
API and filled is
Hmm, that's strange. If you make a Scriptable script with the only thing being the const events = await CalendarEvent.today([])
line, do you still get the error? If not, start re-adding subsequent lines to see when you do start getting errors. If it is just the await CalendarEvent.today([])
line you'll want to reach out to the Scriptable creator.
Without calendar ( all // ) works it
Without calendar ( all // ) works it
Where in the script does the ( all // ) go?
Great script!
I have a question about the Api key from Open Weather. Is such a key dangerous? So it bears risks for hacker attacks, data abuse or what personal data is collected by the website? And do the tracking services on the iPhone always have to be turned on for that? I am thinking about getting such an api key...
Hi Augustus88, an API key is what an API like Open Weather uses to make sure it can control how we're allowed to access the API's info. Each user creates an account and gets their own key from the API, and that way if some bad actor starts querying the API tons of times per minute they are able to revoke that user's key and prevent them from using up Open Weather's resources. The key itself doesn't do any tracking. In order to retrieve data from the API, you need to supply the key and also GPS coordinates (lat and long) for wherever you want weather forecast info. If you only want info for a particular city or area, you can hard-code the GPS coordinates (Option 2 in the code), or you can have the script ask the iPhone's system for your current location and then use those coordinates with the API (Option 1). The former would protect your location info from ever going to Open Weather, but there is also no known vulnerabilities or issues with Open Weather's approach to privacy, so I personally have it set to always pull forecast info for my current location.
Hi there I got one question. If I wanna use your Widget, i need a Code from my Openweathermap API account. So I created a account on the website. But i cant find the required Code. Could you Help me? i sign up on rapidapi.com and at openweathermap.ord. Maybe I choosed the false Website?
Hi
Hi there I got one question. If I wanna use your Widget, i need a Code from my Openweathermap API account. So I created a account on the website. But i cant find the required Code. Could you Help me? i sign up on rapidapi.com and at openweathermap.ord. Maybe I choosed the false Website?
Hi, you need to generate an API Key inside your account.
Hi there I got one question. If I wanna use your Widget, i need a Code from my Openweathermap API account. So I created a account on the website. But i cant find the required Code. Could you Help me? i sign up on rapidapi.com and at openweathermap.ord. Maybe I choosed the false Website?
Hi, as described in my code you have to go to https://openweathermap.org/ (not rapidapi or anything else), make an account, and then after confirming your email with the account go to https://home.openweathermap.org/api_keys (see screenshot for how to get there manually). On that page there's a button to Generate a new API key (see second screenshot). Please do that, and then wait a couple hours for that new key to propagate through their system. Now you can add the API key to my code in the specified string field and run the script.
All right. Thanks a lot you guys.
Great work! Is there a version available with SFSymbols instead of the Open Weather icons?
I haven't created one, but I think you shouldn't have too much trouble copying the way eqsOne modified egamez's Weather Widget:
https://talk.automators.fm/t/widget-examples/7994/414
The main things is to replace the icon
value in dayStack.addImage(icon)
and hourStack.addImage(icon)
with a reference to a custom method you'll need to build like the one eqsOne calls symbolForCondition(cond)
, in order to convert each Open Weather icon with their respective SFSymbols version.
Thanks! I’ve taken a look at eqsOne’s code and have added:
let weatherIcon = dayStack.addImage(addSFS(condition))
function addSFS(cond){
let symbols = {
"2": function(){ return "cloud.bolt.rain.fill” },
"3": function(){ return "cloud.drizzle.fill” },
"5": function(){ return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
"6": function(){ return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
"7": function(){ if (cond == 781) { return "tornado" }
if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
return "sun.haze.fill" },
"8": function(){ if (cond == 800) { return "sun.max.fill" }
if (cond == 802 || cond == 803) { return "cloud.sun.fill" } return "cloud.fill"
}
}
let conditionDigit = Math.floor(cond / 100)
let sfs = SFSymbol.named(symbols[conditionDigit]())
sfs.applyFont(Font.systemFont(8))
return sfs.image
}
I’m unsure how to reference the condition using const condition
though as I’m using the daily forecast only, not hourly. Any advice?
Sure, that looks like a good start, and if you're just using the daily forecast section your const condition
will look like this:
const condition = nextForecast.weather[0].id
. I got that by looking at the format of the json data from Open Weather.
Here's an example URL, you'll want to replace with your own lat/long (these are random, I don't actually live in Ohio haha) and API key:
https://api.openweathermap.org/data/2.5/onecall?lat=40.927755&lon=-80.650477&exclude=current,minutely,alerts&units=imperial&appid={API_KEY}
After that, the only thing left to do is adjust the UI formatting/sizing of the icons to make it look nicer on screen.
Hmm, I'm not sure. That might be a better question for the broader community. I'd make a post on https://talk.automators.fm/t/widget-examples/7994 with your current code.
@deta362 I don't know if you've already found the answer, but to center align them, you put them in a horizontal stack and add a spacer on both sides. This of course only works in a vertical stack.
diff --git a/WeatherCal Widget.js b/WeatherCal Widget.js
index 7dd1aef..a73e77d 100644
--- a/WeatherCal Widget.js
+++ b/WeatherCal Widget.js
@@ -138,20 +138,29 @@ if (config.runsInWidget || DEV_MODE) {
let hourStack = hourlyWeatherStack.addStack()
hourStack.layoutVertically()
- let hourTxt = hourStack.addText(formatHours(nextForecast.dt))
- hourTxt.centerAlignText()
+ let stack = hourStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let hourTxt = stack.addText(formatHours(nextForecast.dt))
hourTxt.font = Font.systemFont(10)
hourTxt.textColor = Color.black()
hourTxt.textOpacity = 0.5
+ stack.addSpacer()
- let weatherIcon = hourStack.addImage(icon)
- weatherIcon.centerAlignImage()
+ stack = hourStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let weatherIcon = stack.addImage(icon)
weatherIcon.size = new Size(25, 25)
+ stack.addSpacer()
- let tempTxt = hourStack.addText(" " + Math.round(nextForecast.temp) + "°")
- tempTxt.centerAlignText()
+ stack = hourStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let tempTxt = stack.addText(Math.round(nextForecast.temp) + "°")
tempTxt.font = Font.systemFont(10)
tempTxt.textColor = Color.black()
+ stack.addSpacer()
}
hourlyWeatherStack.addSpacer()
@@ -180,22 +189,31 @@ if (config.runsInWidget || DEV_MODE) {
let dayStack = dailyWeatherStack.addStack()
dayStack.layoutVertically()
- let hourTxt = dayStack.addText(formatDay(nextForecast.dt))
- hourTxt.centerAlignText()
+ let stack = dayStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let hourTxt = stack.addText(formatDay(nextForecast.dt))
hourTxt.font = Font.systemFont(10)
hourTxt.textColor = Color.black()
hourTxt.textOpacity = 0.5
+ stack.addSpacer()
- let weatherIcon = dayStack.addImage(icon)
- weatherIcon.centerAlignImage()
+ stack = dayStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let weatherIcon = stack.addImage(icon)
weatherIcon.size = new Size(25, 25)
+ stack.addSpacer()
- let tempTxt = dayStack.addText(
+ stack = dayStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let tempTxt = stack.addText(
"" + Math.round(nextForecast.temp.max) +
"-" + Math.round(nextForecast.temp.min) + "°")
- tempTxt.centerAlignText()
tempTxt.font = Font.systemFont(10)
tempTxt.textColor = Color.black()
+ stack.addSpacer()
}
dailyWeatherStack.addSpacer()
@@ -385,14 +403,21 @@ function createSymbolStack(sunStack, UNIX_timestamp, symbolName) {
let sunsetStack = sunStack.addStack()
sunsetStack.layoutVertically()
- let sunsetTxt = sunsetStack.addText(formatHoursMin(UNIX_timestamp))
- sunsetTxt.centerAlignText()
+
+ let stack = sunsetStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
+ let sunsetTxt = stack.addText(formatHoursMin(UNIX_timestamp))
sunsetTxt.font = Font.systemFont(10)
sunsetTxt.textColor = Color.black()
+ stack.addSpacer()
+ stack = sunsetStack.addStack()
+ stack.layoutHorizontally()
+ stack.addSpacer()
let sunsetSymbol = SFSymbol.named(symbolName)
- let sunsetIcon = sunsetStack.addImage(sunsetSymbol.image)
+ let sunsetIcon = stack.addImage(sunsetSymbol.image)
sunsetIcon.tintColor = Color.black()
- sunsetIcon.centerAlignImage()
sunsetIcon.imageSize = new Size(30, 30)
+ stack.addSpacer()
The only problem with that is that some text is now to large and gets cut off with an ellipsis...
Dont work