Skip to content

Instantly share code, notes, and snippets.

@grantavery
Last active April 6, 2021 06:45
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save grantavery/4a9587d9190ec58989670da73f787c97 to your computer and use it in GitHub Desktop.
Save grantavery/4a9587d9190ec58989670da73f787c97 to your computer and use it in GitHub Desktop.
iOS Scriptable Widget to display weather and calendar information

WeatherCal Widget

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)
}
@meister-igi
Copy link

Dont work

@grantavery
Copy link
Author

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?

@meister-igi
Copy link

Error on line 91:43

@grantavery
Copy link
Author

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?

@meister-igi
Copy link

meister-igi commented Oct 26, 2020

Apple Calendar and active Events
API and filled is

@grantavery
Copy link
Author

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.

@meister-igi
Copy link

Without calendar ( all // ) works it

@Leibinger015
Copy link

Without calendar ( all // ) works it

Where in the script does the ( all // ) go?

Copy link

ghost commented Oct 31, 2020

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...

@grantavery
Copy link
Author

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.

@DerCoon3301
Copy link

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?

@baerenmarke90
Copy link

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.

@grantavery
Copy link
Author

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.

Screen Shot 2020-11-04 at 8 00 46 AM

Screen Shot 2020-11-04 at 8 04 17 AM

@DerCoon3301
Copy link

All right. Thanks a lot you guys.

@deta362
Copy link

deta362 commented Dec 28, 2020

Great work! Is there a version available with SFSymbols instead of the Open Weather icons?

@grantavery
Copy link
Author

grantavery commented Dec 28, 2020

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.

@deta362
Copy link

deta362 commented Dec 29, 2020

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?

@grantavery
Copy link
Author

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.

image

@deta362
Copy link

deta362 commented Dec 30, 2020

Great, thanks. I’ve added weatherIcon.imageSize = new Size(16,16) to resize the icons. Is there an option to center align the icons and the Day/Temp text? The centerAlignText and centerAlignImage options don’t have any affect?

403D6B95-793B-408A-A8F4-3E9141999F35

@grantavery
Copy link
Author

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.

@schl3ck
Copy link

schl3ck commented Jan 22, 2021

@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.

The changes to be made would be:
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...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment