Skip to content

Instantly share code, notes, and snippets.

@GeoffreyDMartin
Created October 5, 2020 18:09
Show Gist options
  • Save GeoffreyDMartin/8c7b8325ca762e7620494c02718e449f to your computer and use it in GitHub Desktop.
Save GeoffreyDMartin/8c7b8325ca762e7620494c02718e449f to your computer and use it in GitHub Desktop.
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-purple; icon-glyph: image;
// This widget was complied by Geoffrey Martin (@GeoffreyDMartin) and uses multiple ideas and scripts from the Scriptable Forums at talk.automators.com.
// The "transparency" portion of the script from using code from a widget created by Max Zeryck @mzeryck (see https://gist.github.com/mzeryck/3a97ccd1e059b3afa3c6666d27a496c9)
// The weather portion of the script was taken from ImGamez (see https://gist.github.com/ImGamez/a8f9d77bf660d7703cc96fee87cdc4b0)
/*
* Change the widget settings and test out the widget in this section.
* ===================================================================
*/
/* -- RESET YOUR WIDGET -- */
// Change to true to reset the widget background.
const resetWidget = false
/* -- PREVIEW YOUR WIDGET -- */
// Change to true to see a preview of your widget.
const testMode = true
// Optionally specify the size of your widget preview.
const widgetPreview = "large"
/* -- FONTS AND TEXT -- */
// Use iosfonts.com, or change to "" for the system font.
const fontName = "Futura-Medium"
// Find colors on htmlcolorcodes.com
const fontColor = new Color("#ffffff")
// Change the font sizes for each element.
const dayOfWeekSize = 60
const todaySize = 18
const activeTimerHeaderSize = 14
const activeTimerSize = 36
const untilSize = 14
/* -- WEATHER API -- */
const widgetParams = JSON.parse((args.widgetParameter != null) ? args.widgetParameter : '{ "LAT" : "33.9137" , "LON" : "-98.4943" , "LOC_NAME" : "Wichita Falls, TX" }')
const API_KEY = "REPLACE WITH YOUR OPENWEATHERMAP APIKEY"
const LAT = widgetParams.LAT
const LON = widgetParams.LON
const LOCATION_NAME = widgetParams.LOC_NAME
const units = "imperial"
const roundedGraph = true
const roundedTemp = true
const hoursToShow = (config.widgetFamily == "small") ? 3 : 11;
const spaceBetweenDays = (config.widgetFamily == "small") ? 60 : 44;
// Widget Size !important.
// Since the widget works "making" an image and displaying it as the widget background, you need to specify the exact size of the widget to
// get an 1:1 display ratio, if you specify an smaller size than the widget itself it will be displayed blurry.
// You can get the size simply taking an screenshot of your widgets on the home screen and measuring them in an image-proccessing software.
// contextSize : number > Height of the widget in screen pixels, this depends on you screen size (for an 4 inch display the small widget is 282 * 282 pixels on the home screen)
const contextSize = 222
// mediumWidgetWidth : number > Width of the medium widget in pixels, this depends on you screen size (for an 4 inch display the medium widget is 584 pixels long on the home screen)
const mediumWidgetWidth = 584
// accentColor : Color > Accent color of some elements (Graph lines and the location label).
const accentColor = new Color("#EB6E4E", 1)
// backgroundColor : Color > Background color of the widgets.
const backgroundColor = new Color("#1C1C1E", 0.2)
// Position and size of the elements on the widget.
// All coordinates make reference to the top-left of the element.
// locationNameCoords : Point > Define the position in pixels of the location label.
const locationNameCoords = new Point(30, 20)
// locationNameFontSize : number > Size in pixels of the font of the location label.
const locationNameFontSize = 36
// locationNameCoords : Point > Define the position in pixels of the location label.
const tempCoords = new Rect((config.widgetFamily == "small") ? 150 : 450, 20, 100, 36)
// locationNameFontSize : number > Size in pixels of the font of the location label.
const tempFontSize = 36
// weatherDescriptionCoords : Point > Position of the weather description label in pixels.
const weatherDescriptionCoords = new Rect((config.widgetFamily == "small") ? 150 : 450, 62, 100, 36)
// weatherDescriptionFontSize : number > Font size of the weather description label.
const weatherDescriptionFontSize = 18
//footerFontSize : number > Font size of the footer labels (feels like... and last update time).
const footerFontSize = 20
//feelsLikeCoords : Point > Coordinates of the "feels like" label.
const feelsLikeCoords = new Point(30, 230)
//lastUpdateTimePosAndSize : Rect > Defines the coordinates and size of the last updated time label.
const lastUpdateTimePosAndSize = new Rect((config.widgetFamily == "small") ? 150 : 450, 230, 100, footerFontSize + 1)
// Prepare for the SFSymbol request by getting sunset/sunrise times.
const date = new Date()
const sunData = await new Request("https://api.sunrise-sunset.org/json?lat=" + LAT + "&lng=" + LON + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate()).loadJSON();
//From here proceed with caution.
let fm = FileManager.iCloud();
let cachePath = fm.joinPath(fm.documentsDirectory(), "weatherCache");
if (!fm.fileExists(cachePath)) {
fm.createDirectory(cachePath)
}
let weatherData;
let usingCachedData = false;
let drawContext = new DrawContext();
drawContext.size = new Size((config.widgetFamily == "small") ? contextSize : mediumWidgetWidth, contextSize)
drawContext.opaque = false
drawContext.setTextAlignedCenter()
try {
weatherData = await new Request("https://api.openweathermap.org/data/2.5/onecall?lat=" + LAT + "&lon=" + LON + "&exclude=daily,minutely,alerts&units=" + units + "&lang=en&appid=" + API_KEY).loadJSON();
fm.writeString(fm.joinPath(cachePath, "lastread"), JSON.stringify(weatherData));
} catch (e) {
console.log("Offline mode")
try {
let raw = fm.readString(fm.joinPath(cachePath, "lastread"));
weatherData = JSON.parse(raw);
usingCachedData = true;
} catch (e2) {
console.log("Error: No offline data cached")
}
}
/* -- END WEATHER PROPERITIES -- */
/*
* The code below this comment is the widget logic - a bit more complex.
* =====================================================================
*/
/* -- GLOBAL VALUES -- */
// Widgets are unique based on the name of the script.
const filename = Script.name() + ".jpg"
const files = FileManager.local()
const path = files.joinPath(files.documentsDirectory(), filename)
const fileExists = files.fileExists(path)
// Store other global values.
let widget = new ListWidget()
// If we're in the widget or testing, build the widget.
if (config.runsInWidget || (testMode && fileExists && !resetWidget)) {
widget.setPadding(0, 0, 0, 0);
widget.backgroundImage = files.readImage(path)
/* -- ENTER YOUR CUSTOM WIDGET UI HERE -- */
let formatter = new DateFormatter()
formatter.dateFormat = "EEEE"
let dayOfWeekString = formatter.string(new Date()).toUpperCase()
let dayOfWeekText = widget.addText(dayOfWeekString)
dayOfWeekText.textColor = fontColor
dayOfWeekText.font = new Font(fontName, dayOfWeekSize)
dayOfWeekText.lineLimit = 1
dayOfWeekText.minimumScaleFactor = 0.2
dayOfWeekText.centerAlignText()
formatter.dateFormat = "MMMM d, yyyy"
let dateString = formatter.string(new Date())
let dateText = widget.addText(dateString)
dateText.textColor = fontColor
dateText.font = new Font(fontName, todaySize)
dateText.lineLimit = 1
dateText.minimumScaleFactor = 0.2
dateText.centerAlignText()
widget.addSpacer(25)
/* -- WEATHER PORTION UI by ImGamez -- */
drawText(LOCATION_NAME, locationNameFontSize, locationNameCoords.x, locationNameCoords.y, Color.white());
drawContext.setTextAlignedRight();
drawTextC(Math.round(weatherData.current.temp) + "°", tempFontSize, tempCoords.x, tempCoords.y, tempCoords.width, tempCoords.height, Color.white());
drawTextC(weatherData.current.weather[0].description, weatherDescriptionFontSize, weatherDescriptionCoords.x, weatherDescriptionCoords.y, weatherDescriptionCoords.width, weatherDescriptionCoords.height, Color.white())
drawContext.setTextAlignedRight();
let min, max, diff;
for (let i = 0; i <= hoursToShow; i++) {
let temp = shouldRound(roundedGraph, weatherData.hourly[i].temp);
min = (temp < min || min == undefined ? temp : min)
max = (temp > max || max == undefined ? temp : max)
}
diff = max - min;
for (let i = 0; i <= hoursToShow; i++) {
let hourData = weatherData.hourly[i];
let nextHourTemp = shouldRound(roundedGraph, weatherData.hourly[i + 1].temp);
let hour = epochToDate(hourData.dt).getHours();
hour = (hour > 12 ? hour - 12 : (hour == 0 ? "12a" : ((hour == 12) ? "12p" : hour)))
let temp = i == 0 ? weatherData.current.temp : hourData.temp
let delta = (diff > 0) ? (shouldRound(roundedGraph, temp) - min) / diff : 0.5;
let nextDelta = (diff > 0) ? (nextHourTemp - min) / diff : 0.5
if (i < hoursToShow)
drawLine(spaceBetweenDays * (i) + 50, 175 - (50 * delta), spaceBetweenDays * (i + 1) + 50, 175 - (50 * nextDelta), 4, (hourData.dt > weatherData.current.sunset ? Color.gray() : accentColor))
drawContext.setTextAlignedCenter();
drawTextC(shouldRound(roundedTemp, temp) + "°", 20, spaceBetweenDays * i + 30, 135 - (50 * delta), 50, 21, Color.white())
// The next three lines were modified for SFSymbol support.
const condition = i == 0 ? weatherData.current.weather[0].id : hourData.weather[0].id
const condDate = i == 0 ? weatherData.current.dt : hourData.dt
drawImage(symbolForCondition(condition, condDate), spaceBetweenDays * i + 40, 165 - (50 * delta));
drawContext.setTextAlignedCenter();
drawTextC((i == 0 ? "Now" : hour), 18, spaceBetweenDays * i + 30, 200, 50, 21, Color.gray())
drawContext.setTextAlignedLeft();
previousDelta = delta;
}
let weatherUI = widget.addImage(drawContext.getImage())
weatherUI.centerAlignImage()
widget.addSpacer(25)
/* -- MORE CUSTOM UI UNDER THE WEATHER UI -- */
let moreText = widget.addText("This is more text")
moreText.font = new Font(fontName, 18)
moreText.textColor = fontColor
moreText.centerAlignText()
/* -- End CUSTOM UI CODE-- */
Script.setWidget(widget)
// This previews the widget in Scriptable
if (testMode) {
let widgetSizeFormat = widgetPreview.toLowerCase()
if (widgetSizeFormat == "small") {
widget.presentSmall()
}
if (widgetSizeFormat == "medium") {
widget.presentMedium()
}
if (widgetSizeFormat == "large") {
widget.presentLarge()
}
}
Script.complete()
// If we're running normally, go to the calendar.
} else if (fileExists && !resetWidget) {
const appleDate = new Date('2001/01/01')
const timestamp = (date.getTime() - appleDate.getTime()) / 1000
const callback = new CallbackURL("calshow:" + timestamp)
callback.open()
Script.complete()
// If it's the first time it's running, set up the widget background.
} else {
// Determine if user has taken the screenshot.
var message
message = "Before you start, go to your home screen and enter wiggle mode. Scroll to the empty page on the far right and take a screenshot."
let exitOptions = ["Continue", "Exit to Take Screenshot"]
let shouldExit = await generateAlert(message, exitOptions)
if (shouldExit) return
// Get screenshot and determine phone size.
let img = await Photos.fromLibrary()
let height = img.size.height
let phone = phoneSizes()[height]
if (!phone) {
message = "It looks like you selected an image that isn't an iPhone screenshot, or your iPhone is not supported. Try again with a different image."
await generateAlert(message, ["OK"])
return
}
// Prompt for widget size and position.
message = "What size of widget are you creating?"
let sizes = ["Small", "Medium", "Large"]
let size = await generateAlert(message, sizes)
let widgetSize = sizes[size]
message = "What position will it be in?"
message += (height == 1136 ? " (Note that your device only supports two rows of widgets, so the middle and bottom options are the same.)" : "")
// Determine image crop based on phone size.
let crop = {
w: "",
h: "",
x: "",
y: ""
}
if (widgetSize == "Small") {
crop.w = phone.small
crop.h = phone.small
let positions = ["Top left", "Top right", "Middle left", "Middle right", "Bottom left", "Bottom right"]
let position = await generateAlert(message, positions)
// Convert the two words into two keys for the phone size dictionary.
let keys = positions[position].toLowerCase().split(' ')
crop.y = phone[keys[0]]
crop.x = phone[keys[1]]
} else if (widgetSize == "Medium") {
crop.w = phone.medium
crop.h = phone.small
// Medium and large widgets have a fixed x-value.
crop.x = phone.left
let positions = ["Top", "Middle", "Bottom"]
let position = await generateAlert(message, positions)
let key = positions[position].toLowerCase()
crop.y = phone[key]
} else if (widgetSize == "Large") {
crop.w = phone.medium
crop.h = phone.large
crop.x = phone.left
let positions = ["Top", "Bottom"]
let position = await generateAlert(message, positions)
// Large widgets at the bottom have the "middle" y-value.
crop.y = position ? phone.middle : phone.top
}
// Crop image and finalize the widget.
let imgCrop = cropImage(img, new Rect(crop.x, crop.y, crop.w, crop.h))
files.writeImage(path, imgCrop)
message = "Your widget background is ready. If you haven't already granted Calendar access, it will pop up next."
await generateAlert(message, ["OK"])
// Make sure we have calendar access.
await CalendarEvent.today([])
Script.complete()
}
/*
* Helper functions
* ================
*/
async function loadImage(imgName) {
if (fm.fileExists(fm.joinPath(cachePath, imgName))) {
return Image.fromData(Data.fromFile(fm.joinPath(cachePath, imgName)))
} else {
let imgdata = await new Request("https://openweathermap.org/img/wn/" + imgName + ".png").load();
let img = Image.fromData(imgdata);
fm.write(fm.joinPath(cachePath, imgName), imgdata);
return img;
}
}
function epochToDate(epoch) {
return new Date(epoch * 1000)
}
function drawText(text, fontSize, x, y, color = Color.black()) {
drawContext.setFont(Font.boldSystemFont(fontSize))
drawContext.setTextColor(color)
drawContext.drawText(new String(text).toString(), new Point(x, y))
}
function drawImage(image, x, y) {
drawContext.drawImageAtPoint(image, new Point(x, y))
}
function drawTextC(text, fontSize, x, y, w, h, color = Color.black()) {
drawContext.setFont(Font.boldSystemFont(fontSize))
drawContext.setTextColor(color)
drawContext.drawTextInRect(new String(text).toString(), new Rect(x, y, w, h))
}
function drawLine(x1, y1, x2, y2, width, color) {
const path = new Path()
path.move(new Point(x1, y1))
path.addLine(new Point(x2, y2))
drawContext.addPath(path)
drawContext.setStrokeColor(color)
drawContext.setLineWidth(width)
drawContext.strokePath()
}
function shouldRound(should, value) {
return ((should) ? Math.round(value) : value)
}
// This function returns an SFSymbol image for a weather condition.
function symbolForCondition(cond, condDate) {
const sunrise = new Date(sunData.results.sunrise).getTime()
const sunset = new Date(sunData.results.sunset).getTime()
const timeValue = condDate * 1000
// Is it night at the provided date?
const night = (timeValue < sunrise) || (timeValue > sunset)
// Define our symbol equivalencies.
let symbols = {
// Thunderstorm
"2": function() {
return "cloud.bolt.rain.fill"
},
// Drizzle
"3": function() {
return "cloud.drizzle.fill"
},
// Rain
"5": function() {
return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill"
},
// Snow
"6": function() {
return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow"
},
// Atmosphere
"7": function() {
if (cond == 781) {
return "tornado"
}
if (cond == 701 || cond == 741) {
return "cloud.fog.fill"
}
return night ? "cloud.fog.fill" : "sun.haze.fill"
},
// Clear and clouds
"8": function() {
if (cond == 800) {
return night ? "moon.stars.fill" : "sun.max.fill"
}
if (cond == 802 || cond == 803) {
return night ? "cloud.moon.fill" : "cloud.sun.fill"
}
return "cloud.fill"
}
}
// Find out the first digit.
let conditionDigit = Math.floor(cond / 100)
// Get the symbol.
let condImage = SFSymbol.named(symbols[conditionDigit]())
condImage.applyFont(Font.title2())
return condImage.image
}
// Generate an alert with the provided array of options.
async function generateAlert(message, options) {
let alert = new Alert()
alert.message = message
for (const option of options) {
alert.addAction(option)
}
let response = await alert.presentAlert()
return response
}
// Crop an image into the specified rect.
function cropImage(img, rect) {
let draw = new DrawContext()
draw.size = new Size(rect.width, rect.height)
draw.drawImageAtPoint(img, new Point(-rect.x, -rect.y))
return draw.getImage()
}
// Pixel sizes and positions for widgets on all supported phones.
function phoneSizes() {
let phones = {
"2688": {
"small": 507,
"medium": 1080,
"large": 1137,
"left": 81,
"right": 654,
"top": 228,
"middle": 858,
"bottom": 1488
},
"1792": {
"small": 338,
"medium": 720,
"large": 758,
"left": 54,
"right": 436,
"top": 160,
"middle": 580,
"bottom": 1000
},
"2436": {
"small": 465,
"medium": 987,
"large": 1035,
"left": 69,
"right": 591,
"top": 213,
"middle": 783,
"bottom": 1353
},
"2208": {
"small": 471,
"medium": 1044,
"large": 1071,
"left": 99,
"right": 672,
"top": 114,
"middle": 696,
"bottom": 1278
},
"1334": {
"small": 296,
"medium": 642,
"large": 648,
"left": 54,
"right": 400,
"top": 60,
"middle": 412,
"bottom": 764
},
"1136": {
"small": 282,
"medium": 584,
"large": 622,
"left": 30,
"right": 332,
"top": 59,
"middle": 399,
"bottom": 399
},
"1624": {
"small": 310,
"medium": 658,
"large": 690,
"left": 46,
"right": 394,
"top": 142,
"middle": 522,
"bottom": 902
}
}
return phones
}
@kmo425
Copy link

kmo425 commented Oct 12, 2020

this is EXACTLY what I was looking for! Thank you so much! ''

One question - How do I add calendar events below the weather line piece like in your screenshot?

@JonathanXDR
Copy link

CCCE7B5C-F6BA-43A6-BDFB-2AA325B30200

Everytime I get this error :(

@jmullis3
Copy link

I keep getting this error, not sure why because I’m signed into iCloud. Any suggestions how to go about troubleshooting? Thanks.
Uploading IMG_0360.png…

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