· 5 years ago · Oct 22, 2020, 08:26 PM
1/*
2 * SETUP
3 * Use this section to set up the widget.
4 * ======================================
5 */
6
7// To use weather, get a free API key at openweathermap.org/appid and paste it in between the quotation marks.
8const apiKey = ""
9
10// Set the locale code. Leave blank "" to match the device's locale. You can change the hard-coded text strings in the TEXT section below.
11let locale = "en"
12
13// Set to true for fixed location, false to update location as you move around
14const lockLocation = true
15
16// The size of the widget preview in the app.
17const widgetPreview = "medium"
18
19// Set to true for an image background, false for no image.
20const imageBackground = true
21
22// Set to true to reset the widget's background image.
23const forceImageUpdate = false
24
25// Set the padding around each item. Default is 5.
26const padding = 5
27
28/*
29 * LAYOUT
30 * Decide what items to show on the widget.
31 * ========================================
32 */
33
34// You always need to start with "row," and "column," items, but you can now add as many as you want.
35// Adding left, right, or center will align everything after that. The default alignment is left.
36
37// You can add a flexible vertical space with "space," or a fixed-size space like this: "space(50)"
38// Align items to the top or bottom of columns by adding "space," before or after all items in the column.
39
40// There are many possible items, including: date, greeting, events, current, future, battery, sunrise, and text("Your text here")
41// Make sure to always put a comma after each item.
42
43const items = [
44
45 row,
46 column,
47 greeting,
48 date,
49 events,
50 tasks,
51
52
53
54
55 column(83),
56 current,
57 future,
58
59
60
61]
62
63/*
64 * ITEM SETTINGS
65 * Choose how each item is displayed.
66 * ==================================
67 */
68
69// DATE
70// ====
71const dateSettings = {
72
73 // If set to true, date will become smaller when events are displayed.
74 dynamicDateSize: true
75
76 // If the date is not dynamic, should it be large or small?
77 ,staticDateSize: "small"
78
79 // Determine the date format for each date type. See docs.scriptable.app/dateformatter
80 ,smallDateFormat: "EEEE, MMMM d"
81 ,largeDateLineOne: "EEEE"
82 ,largeDateLineTwo: "MMMM, d"
83}
84
85// EVENTS
86// ======
87const eventSettings = {
88
89 // How many events to show.
90 numberOfEvents: 2
91
92 // Show all-day events.
93 ,showAllDay: true
94
95 // Show tomorrow's events.
96 ,showTomorrow: true
97
98 // Can be blank "" or set to "duration" or "time" to display how long an event is.
99 ,showEventLength: "duration"
100
101 // Set which calendars for which to show events. Empty [] means all calendars.
102 ,selectCalendars: []
103
104 // Leave blank "" for no color, or specify shape (circle, rectangle) and/or side (left, right).
105 ,showCalendarColor: "rectangle left"
106
107 // When no events remain, show a hard-coded "message", a "greeting", or "none".
108 ,noEventBehavior: "message"
109}
110
111//REMINDERS
112const numberOfTasks = 2
113
114// SUNRISE
115// =======
116const sunriseSettings = {
117
118 // How many minutes before/after sunrise or sunset to show this element. 0 for always.
119 showWithin: 0
120}
121
122// WEATHER
123// =======
124const weatherSettings = {
125
126 // Set to imperial for Fahrenheit, or metric for Celsius
127 units: "metric"
128
129 // Show the location of the current weather.
130 ,showLocation: false
131
132 // Show the text description of the current conditions.
133 ,showCondition: false
134
135 // Show today's high and low temperatures.
136 ,showHighLow: true
137
138 // Set the hour (in 24-hour time) to switch to tomorrow's weather. Set to 24 to never show it.
139 ,tomorrowShownAtHour: 20
140}
141
142/*
143 * TEXT
144 * Change the language and formatting of text displayed.
145 * =====================================================
146 */
147
148// You can change the language or wording of any text in the widget.
149const localizedText = {
150
151 // The text shown if you add a greeting item to the layout.
152 nightGreeting: "Good night."
153 ,morningGreeting: "Good morning."
154 ,afternoonGreeting: "Good afternoon."
155 ,eveningGreeting: "Good evening."
156
157 // The text shown if you add a future weather item to the layout, or tomorrow's events.
158 ,nextHourLabel: "Next hour"
159 ,tomorrowLabel: "Tomorrow"
160
161 // Shown when noEventBehavior is set to "message".
162 ,noEventMessage: "Enjoy the rest of your day."
163
164 // The text shown after the hours and minutes of an event duration.
165 ,durationMinute: "m"
166 ,durationHour: "h"
167
168}
169
170// Set the font, size, and color of various text elements. Use iosfonts.com to find fonts to use. If you want to use the default iOS font, set the font name to one of the following: ultralight, light, regular, medium, semibold, bold, heavy, black, or italic.
171const textFormat = {
172
173 // Set the default font and color.
174 defaultText: { size: 14, color: "ffffff", font: "regular" },
175
176 // Any blank values will use the default.
177 smallDate: { size: 17, color: "", font: "semibold" },
178 largeDate1: { size: 22, color: "", font: "light" },
179 largeDate2: { size: 17, color: "", font: "light" },
180
181 greeting: { size: 30, color: "", font: "bold"},
182 eventLabel: { size: 14, color: "", font: "semibold" },
183 eventTitle: { size: 14, color: "", font: "semibold" },
184 eventTime: { size: 14, color: "ffffffcc", font: "" },
185 noEvents: { size: 16, color: "", font: "semibold" },
186
187 largeTemp: { size: 34, color: "", font: "light" },
188 smallTemp: { size: 14, color: "", font: "" },
189 tinyTemp: { size: 12, color: "", font: "" },
190
191 customText: { size: 14, color: "", font: "" },
192
193 battery: { size: 14, color: "", font: "medium" },
194 sunrise: { size: 14, color: "", font: "medium" },
195}
196
197/*
198 * WIDGET CODE
199 * Be more careful editing this section.
200 * =====================================
201 */
202
203// Make sure we have a locale value.
204if (locale == "" || locale == null) { locale = Device.locale() }
205
206// Declare the data variables.
207var eventData, locationData, sunData, weatherData
208
209// Create global constants.
210const currentDate = new Date()
211const files = FileManager.local()
212const allTasks = await Reminder.incompleteDueToday()
213
214
215/*
216 * CONSTRUCTION
217 * ============
218 */
219
220// Set up the widget with padding.
221const widget = new ListWidget()
222const horizontalPad = padding < 10 ? 10 - padding : 10
223const verticalPad = padding < 15 ? 15 - padding : 15
224widget.setPadding(horizontalPad, verticalPad, horizontalPad, verticalPad)
225widget.spacing = 0
226
227// Set up the global variables.
228var currentRow = {}
229var currentColumn = {}
230
231// Set up the initial alignment.
232var currentAlignment = alignLeft
233
234// Set up our items.
235for (item of items) { await item(currentColumn) }
236
237/*
238 * BACKGROUND DISPLAY
239 * ==================
240 */
241
242// If it's an image background, display it.
243if (imageBackground) {
244
245 // Determine if our image exists and when it was saved.
246 const path = files.joinPath(files.documentsDirectory(), "weather-cal-image")
247 const exists = files.fileExists(path)
248
249 // If it exists and an update isn't forced, use the cache.
250 if (exists && (config.runsInWidget || !forceImageUpdate)) {
251 widget.backgroundImage = files.readImage(path)
252
253 // If it's missing when running in the widget, use a gray background.
254 } else if (!exists && config.runsInWidget) {
255 widget.backgroundColor = Color.gray()
256
257 // But if we're running in app, prompt the user for the image.
258 } else {
259 const img = await Photos.fromLibrary()
260 widget.backgroundImage = img
261 files.writeImage(path, img)
262 }
263
264// If it's not an image background, show the gradient.
265} else {
266 let gradient = new LinearGradient()
267 let gradientSettings = await setupGradient()
268
269 gradient.colors = gradientSettings.color()
270 gradient.locations = gradientSettings.position()
271
272 widget.backgroundGradient = gradient
273}
274
275// Finish the widget and show a preview.
276Script.setWidget(widget)
277if (widgetPreview == "small") { widget.presentSmall() }
278else if (widgetPreview == "medium") { widget.presentMedium() }
279else if (widgetPreview == "large") { widget.presentLarge() }
280Script.complete()
281
282/*
283 * LAYOUT FUNCTIONS
284 * These functions manage spacing and alignment.
285 * =============================================
286 */
287
288// Makes a new row on the widget.
289function row(input = null) {
290
291 function makeRow() {
292 currentRow = widget.addStack()
293 currentRow.layoutHorizontally()
294 currentRow.setPadding(0, 0, 0, 0)
295 currentColumn.spacing = 0
296
297 // If input was given, make a column of that size.
298 if (input > 0) { currentRow.size = new Size(0,input) }
299 }
300
301 // If there's no input or it's a number, it's being called in the layout declaration.
302 if (!input || typeof input == "number") { return makeRow }
303
304 // Otherwise, it's being called in the generator.
305 else { makeRow() }
306}
307
308// Makes a new column on the widget.
309function column(input = null) {
310
311 function makeColumn() {
312 currentColumn = currentRow.addStack()
313 currentColumn.layoutVertically()
314 currentColumn.setPadding(0, 0, 0, 0)
315 currentColumn.spacing = 0
316
317 // If input was given, make a column of that size.
318 if (input > 0) { currentColumn.size = new Size(input,0) }
319 }
320
321 // If there's no input or it's a number, it's being called in the layout declaration.
322 if (!input || typeof input == "number") { return makeColumn }
323
324 // Otherwise, it's being called in the generator.
325 else { makeColumn() }
326}
327
328// Create an aligned stack to add content to.
329function align(column) {
330
331 // Add the containing stack to the column.
332 let alignmentStack = column.addStack()
333 alignmentStack.layoutHorizontally()
334
335 // Get the correct stack from the alignment function.
336 let returnStack = currentAlignment(alignmentStack)
337 returnStack.layoutVertically()
338 return returnStack
339}
340
341// Create a right-aligned stack.
342function alignRight(alignmentStack) {
343 alignmentStack.addSpacer()
344 let returnStack = alignmentStack.addStack()
345 return returnStack
346}
347
348// Create a left-aligned stack.
349function alignLeft(alignmentStack) {
350 let returnStack = alignmentStack.addStack()
351 alignmentStack.addSpacer()
352 return returnStack
353}
354
355// Create a center-aligned stack.
356function alignCenter(alignmentStack) {
357 alignmentStack.addSpacer()
358 let returnStack = alignmentStack.addStack()
359 alignmentStack.addSpacer()
360 return returnStack
361}
362
363// This function adds a space, with an optional amount.
364function space(input = null) {
365
366 // This function adds a spacer with the input width.
367 function spacer(column) {
368
369 // If the input is null or zero, add a flexible spacer.
370 if (!input || input == 0) { column.addSpacer() }
371
372 // Otherwise, add a space with the specified length.
373 else { column.addSpacer(input) }
374 }
375
376 // If there's no input or it's a number, it's being called in the column declaration.
377 if (!input || typeof input == "number") { return spacer }
378
379 // Otherwise, it's being called in the column generator.
380 else { input.addSpacer() }
381}
382
383// Change the current alignment to right.
384function right(x) { currentAlignment = alignRight }
385
386// Change the current alignment to left.
387function left(x) { currentAlignment = alignLeft }
388
389// Change the current alignment to center.
390function center(x) { currentAlignment = alignCenter }
391
392/*
393 * SETUP FUNCTIONS
394 * These functions prepare data needed for items.
395 * ==============================================
396 */
397
398// Set up the eventData object.
399async function setupEvents() {
400
401 eventData = {}
402 const calendars = eventSettings.selectCalendars
403 const numberOfEvents = eventSettings.numberOfEvents
404
405 // Function to determine if an event should be shown.
406 function shouldShowEvent(event) {
407
408 // If events are filtered and the calendar isn't in the selected calendars, return false.
409 if (calendars.length && !calendars.includes(event.calendar.title)) { return false }
410
411 // Hack to remove canceled Office 365 events.
412 if (event.title.startsWith("Canceled:")) { return false }
413
414 // If it's an all-day event, only show if the setting is active.
415 if (event.isAllDay) { return eventSettings.showAllDay }
416
417 // Otherwise, return the event if it's in the future.
418 return (event.startDate.getTime() > currentDate.getTime())
419 }
420
421 // Determine which events to show, and how many.
422 const todayEvents = await CalendarEvent.today([])
423 let shownEvents = 0
424 let futureEvents = []
425
426 for (const event of todayEvents) {
427 if (shownEvents == numberOfEvents) { break }
428 if (shouldShowEvent(event)) {
429 futureEvents.push(event)
430 shownEvents++
431 }
432 }
433
434 // If there's room and we need to, show tomorrow's events.
435 let multipleTomorrowEvents = false
436 if (eventSettings.showTomorrow && shownEvents < numberOfEvents) {
437
438 const tomorrowEvents = await CalendarEvent.tomorrow([])
439 for (const event of tomorrowEvents) {
440 if (shownEvents == numberOfEvents) { break }
441 if (shouldShowEvent(event)) {
442
443 // Add the tomorrow label prior to the first tomorrow event.
444 if (!multipleTomorrowEvents) {
445
446 // The tomorrow label is pretending to be an event.
447 futureEvents.push({ title: localizedText.tomorrowLabel.toUpperCase(), isLabel: true })
448 multipleTomorrowEvents = true
449 }
450
451 // Show the tomorrow event and increment the counter.
452 futureEvents.push(event)
453 shownEvents++
454 }
455 }
456 }
457
458 // Store the future events, and whether or not any events are displayed.
459 eventData.futureEvents = futureEvents
460 eventData.eventsAreVisible = (futureEvents.length > 0) && (eventSettings.numberOfEvents > 0)
461}
462
463// Set up the gradient for the widget background.
464async function setupGradient() {
465
466 // Requirements: sunrise
467 if (!sunData) { await setupSunrise() }
468
469 let gradient = {
470 dawn: {
471 color() { return [new Color("142C52"), new Color("1B416F"), new Color("62668B")] },
472 position() { return [0, 0.5, 1] },
473 },
474
475 sunrise: {
476 color() { return [new Color("274875"), new Color("766f8d"), new Color("f0b35e")] },
477 position() { return [0, 0.8, 1.5] },
478 },
479
480 midday: {
481 color() { return [new Color("3a8cc1"), new Color("90c0df")] },
482 position() { return [0, 1] },
483 },
484
485 noon: {
486 color() { return [new Color("b2d0e1"), new Color("80B5DB"), new Color("3a8cc1")] },
487 position() { return [-0.2, 0.2, 1.5] },
488 },
489
490 sunset: {
491 color() { return [new Color("32327A"), new Color("662E55"), new Color("7C2F43")] },
492 position() { return [0.1, 0.9, 1.2] },
493 },
494
495 twilight: {
496 color() { return [new Color("021033"), new Color("16296b"), new Color("414791")] },
497 position() { return [0, 0.5, 1] },
498 },
499
500 night: {
501 color() { return [new Color("16296b"), new Color("021033"), new Color("021033"), new Color("113245")] },
502 position() { return [-0.5, 0.2, 0.5, 1] },
503 },
504 }
505
506 const sunrise = sunData.sunrise
507 const sunset = sunData.sunset
508
509 // Use sunrise or sunset if we're within 30min of it.
510 if (closeTo(sunrise)<=15) { return gradient.sunrise }
511 if (closeTo(sunset)<=15) { return gradient.sunset }
512
513 // In the 30min before/after, use dawn/twilight.
514 if (closeTo(sunrise)<=45 && utcTime < sunrise) { return gradient.dawn }
515 if (closeTo(sunset)<=45 && utcTime > sunset) { return gradient.twilight }
516
517 // Otherwise, if it's night, return night.
518 if (isNight(currentDate)) { return gradient.night }
519
520 // If it's around noon, the sun is high in the sky.
521 if (currentDate.getHours() == 12) { return gradient.noon }
522
523 // Otherwise, return the "typical" theme.
524 return gradient.midday
525}
526
527// Set up the locationData object.
528async function setupLocation() {
529
530 locationData = {}
531 const locationPath = files.joinPath(files.documentsDirectory(), "weather-cal-loc")
532
533 // If our location is unlocked or cache doesn't exist, ask iOS for location.
534 var readLocationFromFile = false
535 if (!lockLocation || !files.fileExists(locationPath)) {
536 try {
537 const location = await Location.current()
538 const geocode = await Location.reverseGeocode(location.latitude, location.longitude, locale)
539 locationData.latitude = location.latitude
540 locationData.longitude = location.longitude
541 locationData.locality = geocode[0].locality
542 files.writeString(locationPath, location.latitude + "|" + location.longitude + "|" + locationData.locality)
543
544 } catch(e) {
545 // If we fail in unlocked mode, read it from the cache.
546 if (!lockLocation) { readLocationFromFile = true }
547
548 // We can't recover if we fail on first run in locked mode.
549 else { return }
550 }
551 }
552
553 // If our location is locked or we need to read from file, do it.
554 if (lockLocation || readLocationFromFile) {
555 const locationStr = files.readString(locationPath).split("|")
556 locationData.latitude = locationStr[0]
557 locationData.longitude = locationStr[1]
558 locationData.locality = locationStr[2]
559 }
560}
561
562// Set up the sunData object.
563async function setupSunrise() {
564
565 // Requirements: location
566 if (!locationData) { await setupLocation() }
567
568 // Set up the sunrise/sunset cache.
569 const sunCachePath = files.joinPath(files.documentsDirectory(), "weather-cal-sun")
570 const sunCacheExists = files.fileExists(sunCachePath)
571 const sunCacheDate = sunCacheExists ? files.modificationDate(sunCachePath) : 0
572 let sunDataRaw, afterSunset
573
574 // If cache exists and was created today, use cached data.
575 if (sunCacheExists && sameDay(currentDate, sunCacheDate)) {
576 const sunCache = files.readString(sunCachePath)
577 sunDataRaw = JSON.parse(sunCache)
578
579 // Determine if it's after sunset.
580 const sunsetDate = new Date(sunDataRaw.results.sunset)
581 afterSunset = currentDate.getTime() - sunsetDate.getTime() > (45 * 60 * 1000)
582 }
583
584 // If we don't have data yet, or we need to get tomorrow's data, get it from the server.
585 if (!sunDataRaw || afterSunset) {
586 let tomorrowDate = new Date()
587 tomorrowDate.setDate(currentDate.getDate() + 1)
588 const dateToUse = afterSunset ? tomorrowDate : currentDate
589 const sunReq = "https://api.sunrise-sunset.org/json?lat=" + locationData.latitude + "&lng=" + locationData.longitude + "&formatted=0&date=" + dateToUse.getFullYear() + "-" + (dateToUse.getMonth()+1) + "-" + dateToUse.getDate()
590 sunDataRaw = await new Request(sunReq).loadJSON()
591 files.writeString(sunCachePath, JSON.stringify(sunDataRaw))
592 }
593
594 // Store the timing values.
595 sunData = {}
596 sunData.sunrise = new Date(sunDataRaw.results.sunrise).getTime()
597 sunData.sunset = new Date(sunDataRaw.results.sunset).getTime()
598}
599
600// Set up the weatherData object.
601async function setupWeather() {
602
603 // Requirements: location
604 if (!locationData) { await setupLocation() }
605
606 // Set up the cache.
607 const cachePath = files.joinPath(files.documentsDirectory(), "weather-cal-cache")
608 const cacheExists = files.fileExists(cachePath)
609 const cacheDate = cacheExists ? files.modificationDate(cachePath) : 0
610 var weatherDataRaw
611
612 // If cache exists and it's been less than 60 seconds since last request, use cached data.
613 if (cacheExists && (currentDate.getTime() - cacheDate.getTime()) < 60000) {
614 const cache = files.readString(cachePath)
615 weatherDataRaw = JSON.parse(cache)
616
617 // Otherwise, use the API to get new weather data.
618 } else {
619 const weatherReq = "https://api.openweathermap.org/data/2.5/onecall?lat=" + locationData.latitude + "&lon=" + locationData.longitude + "&exclude=minutely,alerts&units=" + weatherSettings.units + "&lang=" + locale + "&appid=" + apiKey
620 weatherDataRaw = await new Request(weatherReq).loadJSON()
621 files.writeString(cachePath, JSON.stringify(weatherDataRaw))
622 }
623
624 // Store the weather values.
625 weatherData = {}
626 weatherData.currentTemp = weatherDataRaw.current.temp
627 weatherData.currentCondition = weatherDataRaw.current.weather[0].id
628 weatherData.currentDescription = weatherDataRaw.current.weather[0].main
629 weatherData.todayHigh = weatherDataRaw.daily[0].temp.max
630 weatherData.todayLow = weatherDataRaw.daily[0].temp.min
631
632 weatherData.nextHourTemp = weatherDataRaw.hourly[1].temp
633 weatherData.nextHourCondition = weatherDataRaw.hourly[1].weather[0].id
634
635 weatherData.tomorrowHigh = weatherDataRaw.daily[1].temp.max
636 weatherData.tomorrowLow = weatherDataRaw.daily[1].temp.min
637 weatherData.tomorrowCondition = weatherDataRaw.daily[1].weather[0].id
638}
639
640/*
641 * WIDGET ITEMS
642 * These functions display items on the widget.
643 * ============================================
644 */
645
646// Display the date on the widget.
647async function date(column) {
648
649 // Requirements: events (if dynamicDateSize is enabled)
650 if (!eventData && dateSettings.dynamicDateSize) { await setupEvents() }
651
652 // Set up the date formatter and set its locale.
653 let df = new DateFormatter()
654 df.locale = locale
655
656 // Show small if it's hard coded, or if it's dynamic and events are visible.
657 if (dateSettings.staticDateSize == "small" || (dateSettings.dynamicDateSize && eventData.eventsAreVisible)) {
658 let dateStack = align(column)
659 dateStack.setPadding(padding, padding, padding, padding)
660
661 df.dateFormat = dateSettings.smallDateFormat
662 let dateText = provideText(df.string(currentDate), dateStack, textFormat.smallDate)
663
664 // Otherwise, show the large date.
665 } else {
666 let dateOneStack = align(column)
667 df.dateFormat = dateSettings.largeDateLineOne
668 let dateOne = provideText(df.string(currentDate), dateOneStack, textFormat.largeDate1)
669 dateOneStack.setPadding(padding/2, padding, 0, padding)
670
671 let dateTwoStack = align(column)
672 df.dateFormat = dateSettings.largeDateLineTwo
673 let dateTwo = provideText(df.string(currentDate), dateTwoStack, textFormat.largeDate2)
674 dateTwoStack.setPadding(0, padding, padding, padding)
675 }
676}
677
678// Display a time-based greeting on the widget.
679async function greeting(column) {
680
681 // This function makes a greeting based on the time of day.
682 function makeGreeting() {
683 const hour = currentDate.getHours()
684 if (hour < 5) { return localizedText.nightGreeting }
685 if (hour < 12) { return localizedText.morningGreeting }
686 if (hour-12 < 5) { return localizedText.afternoonGreeting }
687 if (hour-12 < 10) { return localizedText.eveningGreeting }
688 return localizedText.nightGreeting
689 }
690
691 // Set up the greeting.
692 let greetingStack = align(column)
693 let greeting = provideText(makeGreeting(), greetingStack, textFormat.greeting)
694 greetingStack.setPadding(padding, padding, padding, padding)
695}
696
697// Display events on the widget.
698async function events(column) {
699
700 // Requirements: events
701 if (!eventData) { await setupEvents() }
702
703 // If no events are visible, figure out what to do.
704 if (!eventData.eventsAreVisible) {
705 const display = eventSettings.noEventBehavior
706
707 // If it's a greeting, let the greeting function handle it.
708 if (display == "greeting") { return await greeting(column) }
709
710 // If it's a message, get the localized text.
711 if (display == "message" && localizedText.noEventMessage.length) {
712 const messageStack = align(column)
713 messageStack.setPadding(padding, padding, padding, padding)
714 provideText(localizedText.noEventMessage, messageStack, textFormat.noEvents)
715 }
716
717 // Whether or not we displayed something, return here.
718 return
719 }
720
721 // Set up the event stack.
722 let eventStack = column.addStack()
723 eventStack.layoutVertically()
724 const todaySeconds = Math.floor(currentDate.getTime() / 1000) - 978307200
725 eventStack.url = 'calshow:' + todaySeconds
726
727 // If there are no events and we have a message, show it and return.
728 if (!eventData.eventsAreVisible && localizedText.noEventMessage.length) {
729 let message = provideText(localizedText.noEventMessage, eventStack, textFormat.noEvents)
730 eventStack.setPadding(padding, padding, padding, padding)
731 return
732 }
733
734 // If we're not showing the message, don't pad the event stack.
735 eventStack.setPadding(0, 0, 0, 0)
736
737 // Add each event to the stack.
738 var currentStack = eventStack
739 const futureEvents = eventData.futureEvents
740 for (let i = 0; i < futureEvents.length; i++) {
741
742 const event = futureEvents[i]
743 const bottomPadding = (padding-10 < 0) ? 0 : padding-10
744
745 // If it's the tomorrow label, change to the tomorrow stack.
746 if (event.isLabel) {
747 let tomorrowStack = column.addStack()
748 tomorrowStack.layoutVertically()
749 const tomorrowSeconds = Math.floor(currentDate.getTime() / 1000) - 978220800
750 tomorrowStack.url = 'calshow:' + tomorrowSeconds
751 currentStack = tomorrowStack
752
753 // Mimic the formatting of an event title, mostly.
754 const eventLabelStack = align(currentStack)
755 const eventLabel = provideText(event.title, eventLabelStack, textFormat.eventLabel)
756 eventLabelStack.setPadding(padding, padding, padding, padding)
757 continue
758 }
759
760 const titleStack = align(currentStack)
761 titleStack.layoutHorizontally()
762 const showCalendarColor = eventSettings.showCalendarColor
763 const colorShape = showCalendarColor.includes("circle") ? "circle" : "rectangle"
764
765 // If we're showing a color, and it's not shown on the right, add it to the left.
766 if (showCalendarColor.length && !showCalendarColor.includes("right")) {
767 let colorItemText = provideTextSymbol(colorShape) + " "
768 let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
769 colorItem.textColor = event.calendar.color
770 }
771
772 const title = provideText(event.title.trim(), titleStack, textFormat.eventTitle)
773 titleStack.setPadding(padding, padding, event.isAllDay ? padding : padding/5, padding)
774
775 // If we're showing a color on the right, show it.
776 if (showCalendarColor.length && showCalendarColor.includes("right")) {
777 let colorItemText = " " + provideTextSymbol(colorShape)
778 let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
779 colorItem.textColor = event.calendar.color
780 }
781
782 // If there are too many events, limit the line height.
783 if (futureEvents.length >= 3) { title.lineLimit = 1 }
784
785 // If it's an all-day event, we don't need a time.
786 if (event.isAllDay) { continue }
787
788 // Format the time information.
789 let timeText = formatTime(event.startDate)
790
791 // If we show the length as time, add an en dash and the time.
792 if (eventSettings.showEventLength == "time") {
793 timeText += "–" + formatTime(event.endDate)
794
795 // If we should it as a duration, add the minutes.
796 } else if (eventSettings.showEventLength == "duration") {
797 const duration = (event.endDate.getTime() - event.startDate.getTime()) / (1000*60)
798 const hours = Math.floor(duration/60)
799 const minutes = Math.floor(duration % 60)
800 const hourText = hours>0 ? hours + localizedText.durationHour : ""
801 const minuteText = minutes>0 ? minutes + localizedText.durationMinute : ""
802 const showSpace = hourText.length && minuteText.length
803 timeText += " \u2022 " + hourText + (showSpace ? " " : "") + minuteText
804 }
805
806 const timeStack = align(currentStack)
807 const time = provideText(timeText, timeStack, textFormat.eventTime)
808 timeStack.setPadding(0, padding, padding, padding)
809 }
810}
811
812// Display the current weather.
813async function current(column) {
814
815 // Requirements: weather and sunrise
816 if (!weatherData) { await setupWeather() }
817 if (!sunData) { await setupSunrise() }
818
819 // Set up the current weather stack.
820 let currentWeatherStack = column.addStack()
821 currentWeatherStack.layoutVertically()
822 currentWeatherStack.setPadding(0, 0, 0, 0)
823 currentWeatherStack.url = "https://weather.com/weather/today/l/" + locationData.latitude + "," + locationData.longitude
824
825 // If we're showing the location, add it.
826 if (weatherSettings.showLocation) {
827 let locationTextStack = align(currentWeatherStack)
828 let locationText = provideText(locationData.locality, locationTextStack, textFormat.smallTemp)
829 locationTextStack.setPadding(padding, padding, padding, padding)
830 }
831
832 // Show the current condition symbol.
833 let mainConditionStack = align(currentWeatherStack)
834 let mainCondition = mainConditionStack.addImage(provideConditionSymbol(weatherData.currentCondition,isNight(currentDate)))
835 mainCondition.imageSize = new Size(22,22)
836 mainConditionStack.setPadding(weatherSettings.showLocation ? 0 : padding, padding, 0, padding)
837
838 // If we're showing the description, add it.
839 if (weatherSettings.showCondition) {
840 let conditionTextStack = align(currentWeatherStack)
841 let conditionText = provideText(weatherData.currentDescription, conditionTextStack, textFormat.smallTemp)
842 conditionTextStack.setPadding(padding, padding, 0, padding)
843 }
844
845 // Show the current temperature.
846 const tempStack = align(currentWeatherStack)
847 tempStack.setPadding(0, padding, 0, padding)
848 const tempText = Math.round(weatherData.currentTemp) + "°"
849 const temp = provideText(tempText, tempStack, textFormat.largeTemp)
850
851 // If we're not showing the high and low, end it here.
852 if (!weatherSettings.showHighLow) { return }
853
854 // Show the temp bar and high/low values.
855 let tempBarStack = align(currentWeatherStack)
856 tempBarStack.layoutVertically()
857 tempBarStack.setPadding(0, padding, padding, padding)
858
859 let tempBar = drawTempBar()
860 let tempBarImage = tempBarStack.addImage(tempBar)
861 tempBarImage.size = new Size(50,0)
862
863 tempBarStack.addSpacer(1)
864
865 let highLowStack = tempBarStack.addStack()
866 highLowStack.layoutHorizontally()
867
868 const mainLowText = Math.round(weatherData.todayLow).toString()
869 const mainLow = provideText(mainLowText, highLowStack, textFormat.tinyTemp)
870 highLowStack.addSpacer()
871 const mainHighText = Math.round(weatherData.todayHigh).toString()
872 const mainHigh = provideText(mainHighText, highLowStack, textFormat.tinyTemp)
873
874 tempBarStack.size = new Size(60,30)
875}
876
877// Display upcoming weather.
878async function future(column) {
879
880 // Requirements: weather and sunrise
881 if (!weatherData) { await setupWeather() }
882 if (!sunData) { await setupSunrise() }
883
884 // Set up the future weather stack.
885 let futureWeatherStack = column.addStack()
886 futureWeatherStack.layoutVertically()
887 futureWeatherStack.setPadding(0, 0, 0, 0)
888 futureWeatherStack.url = "https://weather.com/weather/tenday/l/" + locationData.latitude + "," + locationData.longitude
889
890 // Determine if we should show the next hour.
891 const showNextHour = (currentDate.getHours() < weatherSettings.tomorrowShownAtHour)
892
893 // Set the label value.
894 const subLabelStack = align(futureWeatherStack)
895 const subLabelText = showNextHour ? localizedText.nextHourLabel : localizedText.tomorrowLabel
896 const subLabel = provideText(subLabelText, subLabelStack, textFormat.smallTemp)
897 subLabelStack.setPadding(0, padding, padding/2, padding)
898
899 // Set up the sub condition stack.
900 let subConditionStack = align(futureWeatherStack)
901 subConditionStack.layoutHorizontally()
902 subConditionStack.centerAlignContent()
903 subConditionStack.setPadding(0, padding, padding, padding)
904
905 // Determine if it will be night in the next hour.
906 var nightCondition
907 if (showNextHour) {
908 const addHour = currentDate.getTime() + (60*60*1000)
909 const newDate = new Date(addHour)
910 nightCondition = isNight(newDate)
911 } else {
912 nightCondition = false
913 }
914
915 let subCondition = subConditionStack.addImage(provideConditionSymbol(showNextHour ? weatherData.nextHourCondition : weatherData.tomorrowCondition,nightCondition))
916 const subConditionSize = showNextHour ? 14 : 18
917 subCondition.imageSize = new Size(subConditionSize, subConditionSize)
918 subConditionStack.addSpacer(5)
919
920 // The next part of the display changes significantly for next hour vs tomorrow.
921 if (showNextHour) {
922 const subTempText = Math.round(weatherData.nextHourTemp) + "°"
923 const subTemp = provideText(subTempText, subConditionStack, textFormat.smallTemp)
924
925 } else {
926 let tomorrowLine = subConditionStack.addImage(drawVerticalLine(new Color("ffffff", 0.5), 20))
927 tomorrowLine.imageSize = new Size(3,28)
928 subConditionStack.addSpacer(5)
929 let tomorrowStack = subConditionStack.addStack()
930 tomorrowStack.layoutVertically()
931
932 const tomorrowHighText = Math.round(weatherData.tomorrowHigh) + ""
933 const tomorrowHigh = provideText(tomorrowHighText, tomorrowStack, textFormat.tinyTemp)
934 tomorrowStack.addSpacer(4)
935 const tomorrowLowText = Math.round(weatherData.tomorrowLow) + ""
936 const tomorrowLow = provideText(tomorrowLowText, tomorrowStack, textFormat.tinyTemp)
937 }
938}
939
940// Return a text-creation function.
941function text(input = null) {
942
943 function displayText(column) {
944
945 // Don't do anything if the input is blank.
946 if (!input || input == "") { return }
947
948 // Otherwise, add the text.
949 const textStack = align(column)
950 textStack.setPadding(padding, padding, padding, padding)
951 const textDisplay = provideText(input, textStack, textFormat.customText)
952 }
953 return displayText
954}
955
956// Add a battery element to the widget; consisting of a battery icon and percentage.
957async function battery(column) {
958
959 // Get battery level via Scriptable function and format it in a convenient way
960 function getBatteryLevel() {
961
962 const batteryLevel = Device.batteryLevel()
963 const batteryPercentage = `${Math.round(batteryLevel * 100)}%`
964
965 return batteryPercentage
966 }
967
968 const batteryLevel = Device.batteryLevel()
969
970 // Set up the battery level item
971 let batteryStack = align(column)
972 batteryStack.layoutHorizontally()
973 batteryStack.centerAlignContent()
974
975 let batteryIcon = batteryStack.addImage(provideBatteryIcon())
976 batteryIcon.imageSize = new Size(30,30)
977
978 // Change the battery icon to red if battery level is <= 20 to match system behavior
979 if ( Math.round(batteryLevel * 100) > 20 || Device.isCharging() ) {
980
981 batteryIcon.tintColor = new Color(textFormat.battery.color || textFormat.defaultText.color)
982
983 } else {
984
985 batteryIcon.tintColor = Color.red()
986
987 }
988
989 batteryStack.addSpacer(padding * 0.6)
990
991 // Display the battery status
992 let batteryInfo = provideText(getBatteryLevel(), batteryStack, textFormat.battery)
993
994 batteryStack.setPadding(padding/2, padding, padding/2, padding)
995
996}
997
998// Show the sunrise or sunset time.
999async function sunrise(column) {
1000
1001 // Requirements: sunrise
1002 if (!sunData) { await setupSunrise() }
1003
1004 const sunrise = sunData.sunrise
1005 const sunset = sunData.sunset
1006 const showWithin = sunriseSettings.showWithin
1007 const closeToSunrise = closeTo(sunrise) <= showWithin
1008 const closeToSunset = closeTo(sunset) <= showWithin
1009
1010 // If we only show sometimes and we're not close, return.
1011 if (showWithin > 0 && !closeToSunrise && !closeToSunset) { return }
1012
1013 // Otherwise, determine which time to show.
1014 const showSunrise = closeTo(sunrise) <= closeTo(sunset)
1015
1016 // Set up the stack.
1017 const sunriseStack = align(column)
1018 sunriseStack.setPadding(padding/2, padding, padding/2, padding)
1019 sunriseStack.layoutHorizontally()
1020 sunriseStack.centerAlignContent()
1021
1022 sunriseStack.addSpacer(padding * 0.3)
1023
1024 // Add the correct symbol.
1025 const symbolName = showSunrise ? "sunrise.fill" : "sunset.fill"
1026 const symbol = sunriseStack.addImage(SFSymbol.named(symbolName).image)
1027 symbol.imageSize = new Size(22,22)
1028
1029 sunriseStack.addSpacer(padding)
1030
1031 // Add the time.
1032 const timeText = formatTime(showSunrise ? new Date(sunrise) : new Date(sunset))
1033 const time = provideText(timeText, sunriseStack, textFormat.sunrise)
1034}
1035
1036// Allow for either term to be used.
1037async function sunset(column) {
1038 return await sunrise(column)
1039}
1040
1041/*
1042 * HELPER FUNCTIONS
1043 * These functions perform duties for other functions.
1044 * ===================================================
1045 */
1046
1047// Determines if the provided date is at night.
1048function isNight(dateInput) {
1049 const timeValue = dateInput.getTime()
1050 return (timeValue < sunData.sunrise) || (timeValue > sunData.sunset)
1051}
1052
1053// Determines if two dates occur on the same day
1054function sameDay(d1, d2) {
1055 return d1.getFullYear() === d2.getFullYear() &&
1056 d1.getMonth() === d2.getMonth() &&
1057 d1.getDate() === d2.getDate()
1058}
1059
1060// Returns the number of minutes between now and the provided date.
1061function closeTo(time) {
1062 return Math.abs(currentDate.getTime() - time) / 60000
1063}
1064
1065// Format the time for a Date input.
1066function formatTime(date) {
1067 let df = new DateFormatter()
1068 df.locale = locale
1069 df.useNoDateStyle()
1070 df.useShortTimeStyle()
1071 return df.string(date)
1072}
1073
1074// Provide a text symbol with the specified shape.
1075function provideTextSymbol(shape) {
1076
1077 // Rectangle character.
1078 if (shape.startsWith("rect")) {
1079 return "\u2759"
1080 }
1081 // Circle character.
1082 if (shape == "circle") {
1083 return "\u2B24"
1084 }
1085 // Default to the rectangle.
1086 return "\u2759"
1087}
1088
1089// Provide a battery SFSymbol with accurate level drawn on top of it.
1090function provideBatteryIcon() {
1091
1092 // If we're charging, show the charging icon.
1093 if (Device.isCharging()) { return SFSymbol.named("battery.100.bolt").image }
1094
1095 // Set the size of the battery icon.
1096 const batteryWidth = 87
1097 const batteryHeight = 41
1098
1099 // Start our draw context.
1100 let draw = new DrawContext()
1101 draw.opaque = false
1102 draw.respectScreenScale = true
1103 draw.size = new Size(batteryWidth, batteryHeight)
1104
1105 // Draw the battery.
1106 draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
1107
1108 // Match the battery level values to the SFSymbol.
1109 const x = batteryWidth*0.1525
1110 const y = batteryHeight*0.247
1111 const width = batteryWidth*0.602
1112 const height = batteryHeight*0.505
1113
1114 // Prevent unreadable icons.
1115 let level = Device.batteryLevel()
1116 if (level < 0.05) { level = 0.05 }
1117
1118 // Determine the width and radius of the battery level.
1119 const current = width * level
1120 let radius = height/6.5
1121
1122 // When it gets low, adjust the radius to match.
1123 if (current < (radius * 2)) { radius = current / 2 }
1124
1125 // Make the path for the battery level.
1126 let barPath = new Path()
1127 barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
1128 draw.addPath(barPath)
1129 draw.setFillColor(Color.black())
1130 draw.fillPath()
1131 return draw.getImage()
1132}
1133
1134// Provide a symbol based on the condition.
1135function provideConditionSymbol(cond,night) {
1136
1137 // Define our symbol equivalencies.
1138 let symbols = {
1139
1140 // Thunderstorm
1141 "2": function() { return "cloud.bolt.rain.fill" },
1142
1143 // Drizzle
1144 "3": function() { return "cloud.drizzle.fill" },
1145
1146 // Rain
1147 "5": function() { return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
1148
1149 // Snow
1150 "6": function() { return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
1151
1152 // Atmosphere
1153 "7": function() {
1154 if (cond == 781) { return "tornado" }
1155 if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
1156 return night ? "cloud.fog.fill" : "sun.haze.fill"
1157 },
1158
1159 // Clear and clouds
1160 "8": function() {
1161 if (cond == 800 || cond == 801) { return night ? "moon.stars.fill" : "sun.max.fill" }
1162 if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" }
1163 return "cloud.fill"
1164 }
1165 }
1166
1167 // Find out the first digit.
1168 let conditionDigit = Math.floor(cond / 100)
1169
1170 // Get the symbol.
1171 return SFSymbol.named(symbols[conditionDigit]()).image
1172}
1173
1174// Provide a font based on the input.
1175function provideFont(fontName, fontSize) {
1176 const fontGenerator = {
1177 "ultralight": function() { return Font.ultraLightSystemFont(fontSize) },
1178 "light": function() { return Font.lightSystemFont(fontSize) },
1179 "regular": function() { return Font.regularSystemFont(fontSize) },
1180 "medium": function() { return Font.mediumSystemFont(fontSize) },
1181 "semibold": function() { return Font.semiboldSystemFont(fontSize) },
1182 "bold": function() { return Font.boldSystemFont(fontSize) },
1183 "heavy": function() { return Font.heavySystemFont(fontSize) },
1184 "black": function() { return Font.blackSystemFont(fontSize) },
1185 "italic": function() { return Font.italicSystemFont(fontSize) }
1186 }
1187
1188 const systemFont = fontGenerator[fontName]
1189 if (systemFont) { return systemFont() }
1190 return new Font(fontName, fontSize)
1191}
1192
1193// Add formatted text to a container.
1194function provideText(string, container, format) {
1195 const textItem = container.addText(string)
1196 const textFont = format.font || textFormat.defaultText.font
1197 const textSize = format.size || textFormat.defaultText.size
1198 const textColor = format.color || textFormat.defaultText.color
1199
1200 textItem.font = provideFont(textFont, textSize)
1201 textItem.textColor = new Color(textColor)
1202 return textItem
1203}
1204
1205/*
1206 * DRAWING FUNCTIONS
1207 * These functions draw onto a canvas.
1208 * ===================================
1209 */
1210
1211// Draw the vertical line in the tomorrow view.
1212function drawVerticalLine(color, height) {
1213
1214 const width = 2
1215
1216 let draw = new DrawContext()
1217 draw.opaque = false
1218 draw.respectScreenScale = true
1219 draw.size = new Size(width,height)
1220
1221 let barPath = new Path()
1222 const barHeight = height
1223 barPath.addRoundedRect(new Rect(0, 0, width, height), width/2, width/2)
1224 draw.addPath(barPath)
1225 draw.setFillColor(color)
1226 draw.fillPath()
1227
1228 return draw.getImage()
1229}
1230
1231// Draw the temp bar.
1232function drawTempBar() {
1233
1234 // Set the size of the temp bar.
1235 const tempBarWidth = 200
1236 const tempBarHeight = 20
1237
1238 // Calculate the current percentage of the high-low range.
1239 let percent = (weatherData.currentTemp - weatherData.todayLow) / (weatherData.todayHigh - weatherData.todayLow)
1240
1241 // If we're out of bounds, clip it.
1242 if (percent < 0) {
1243 percent = 0
1244 } else if (percent > 1) {
1245 percent = 1
1246 }
1247
1248 // Determine the scaled x-value for the current temp.
1249 const currPosition = (tempBarWidth - tempBarHeight) * percent
1250
1251 // Start our draw context.
1252 let draw = new DrawContext()
1253 draw.opaque = false
1254 draw.respectScreenScale = true
1255 draw.size = new Size(tempBarWidth, tempBarHeight)
1256
1257 // Make the path for the bar.
1258 let barPath = new Path()
1259 const barHeight = tempBarHeight - 10
1260 barPath.addRoundedRect(new Rect(0, 5, tempBarWidth, barHeight), barHeight / 2, barHeight / 2)
1261 draw.addPath(barPath)
1262 draw.setFillColor(new Color("ffffff", 0.5))
1263 draw.fillPath()
1264
1265 // Make the path for the current temp indicator.
1266 let currPath = new Path()
1267 currPath.addEllipse(new Rect(currPosition, 0, tempBarHeight, tempBarHeight))
1268 draw.addPath(currPath)
1269 draw.setFillColor(new Color("ffffff", 1))
1270 draw.fillPath()
1271
1272 return draw.getImage()
1273
1274}
1275
1276// Reminders
1277function tasks(column) {
1278
1279 // Sort reminders in chronological order
1280 function sortItems(first, second) {
1281 return first.dueDate - second.dueDate
1282 }
1283
1284 if (allTasks.length > 0) {
1285 let headingStack = align(column)
1286 const heading = provideText(localizedText.remindersHeader, headingStack, textFormat.eventTitle)
1287 headingStack.setPadding(10, 10, 0, 10)
1288
1289 let taskStack = align(column)
1290 allTasks.sort(sortItems).slice(0, numberOfTasks).forEach(({ title, dueDate}) => {
1291
1292 // Show overdue reminders in red with no time
1293 if (dueDate < currentDate) {
1294 const task = provideText(`○ ${title}`, taskStack, textFormat.overdueTask)
1295 taskStack.addSpacer(10)
1296 task.lineLimit = 1;
1297 }
1298
1299 // Otherwise show upcoming reminders normally
1300 else {
1301 const task = provideText(`○ ${title}`, taskStack, textFormat.eventTitle)
1302 taskStack.setPadding(10, 10, 10, 10)
1303 task.lineLimit = 1;
1304
1305 let df = new DateFormatter()
1306 df.useNoDateStyle()
1307 df.useShortTimeStyle()
1308 let timeText = df.string(dueDate)
1309
1310 const due = provideText(timeText, taskStack, textFormat.eventTime)
1311 taskStack.addSpacer(10)
1312 }
1313 });
1314 }
1315}