· 5 years ago · Nov 22, 2020, 06:24 PM
1// Version 0.80 vom 22.11.2020
2
3// Die Idee zu diesem Widget Umbau stammt aus der Facebook Gruppe "Apple-Fans Deutschland".
4// Markus Wittmann hat jemanden gesucht, der ein Widget um eine Funktion aus einem anderen Widget erweitert.
5
6// Als Basis diente der "Weather Cal Widget Builder" aus der Scriptable Gallery.
7// Der Entwickler dahinter ist Max Zeryck alias mzeryck.
8// Sein Projekt (inklusive einer Beschreibung der Konfigurationsmöglichkeiten findet man hier:
9// https://github.com/mzeryck/Weather-Cal
10// Danke an Max für dieses wirklich tolle und flexible Widget. Wirklich gut programmiert und dokumentiert.
11
12// Eingebaut habe ich die Funktionalität aus dem "Telekom Datenvolumen" Widget
13// von olikdesign (https://gist.github.com/olikdesign) (orig. @Sillium (https://github.com/Sillium))
14
15// Wer auf das Telekom Datenvolumen verzichten kann, dem empfehle ich dringend sich Max Script anzusehen.
16// Er hat es zwischenzeitlich viel angepasst und sein Skript ist wirklich unglaublich flexibel und
17// hat auch die ein oder andere funktionale Erweiterung.
18
19// Kurz-Anleitung:
20// 1. Ihr benötigt einen kostenlosen Open Weatermap API Key (gibt es hier: openweathermap.org/appid)
21// um das Wetter angezeigt zu bekommen. Dieser muss weiter unten eingefügt werden:
22// const apiKey = "hier muss Euer Key rein."
23// 2. iOS-App Scriptable aus dem App Store herunterladen
24// 3. Scriptable öffnen und am oberen rechten Bildschirmrand „+“ antippen, um ein neues Skript hinzuzufügen
25// 4. Diese Skript kopieren und in Scriptable Skript einfügen
26// 5. Skript mit Play-Button testen
27// 6. Ggf. erscheinende Sicherheitsabfragen von iOS für die App bestätigen
28// 7. Scriptable schließen
29// 8. Scriptable-Widget (Mittleres) wie gewohnt über längeres Gedrückthalten des Homescreen und „+“ platzieren
30// 9. Über längeres Gedrückthalten des Widgets und „Widget bearbeiten“ das erstellte Skript in Scriptable auswählen
31// 10. Individuell gewünschte Anpassungen vornehmen (z.B. Text in Deutsch oder andere Parameter)
32
33/*
34 * SETUP
35 * Use this section to set up the widget.
36 * ======================================
37 */
38
39// To use weather, get a free API key at openweathermap.org/appid and paste it in between the quotation marks.
40const apiKey = ""
41
42// 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.
43let locale = "de"
44
45// Set to true for fixed location, false to update location as you move around
46const lockLocation = false
47
48// The size of the widget preview in the app.
49const widgetPreview = "medium"
50
51// Set to true for an image background, false for no image.
52const imageBackground = false
53
54// Set to true to reset the widget's background image.
55const forceImageUpdate = false
56
57// Set the padding around each item. Default is 5.
58const padding = 2
59
60// Decide if icons should match the color of the text around them.
61const tintIcons = false
62
63/*
64 * LAYOUT
65 * Decide what items to show on the widget.
66 * ========================================
67 */
68
69// You always need to start with "row," and "column," items, but you can now add as many as you want.
70// Adding left, right, or center will align everything after that. The default alignment is left.
71
72// You can add a flexible vertical space with "space," or a fixed-size space like this: "space(50)"
73// Align items to the top or bottom of columns by adding "space," before or after all items in the column.
74
75// There are many possible items, including: date, greeting, events, current, future, battery, sunrise, and text("Your text here")
76// Make sure to always put a comma after each item.
77
78const items = [
79
80 row,
81
82 column,
83 date,
84 telekom,
85
86 column(90),
87 current,
88
89 row,
90
91 column,
92 battery,
93
94 column,
95 sunrise,
96
97 column(90),
98 future,
99
100]
101
102/*
103 * ITEM SETTINGS
104 * Choose how each item is displayed.
105 * ==================================
106 */
107
108// DATE
109// ====
110const dateSettings = {
111
112 // If set to true, date will become smaller when events are displayed.
113 dynamicDateSize: false
114
115 // If the date is not dynamic, should it be large or small?
116 ,staticDateSize: "large"
117
118 // Determine the date format for each date type. See docs.scriptable.app/dateformatter
119 ,smallDateFormat: "EEEE, dd. MMMM"
120 ,largeDateLineOne: "EEEE,"
121 ,largeDateLineTwo: "dd. MMMM"
122}
123
124// EVENTS
125// ======
126const eventSettings = {
127
128 // How many events to show.
129 numberOfEvents: 3
130
131 // Show all-day events.
132 ,showAllDay: true
133
134 // Show tomorrow's events.
135 ,showTomorrow: true
136
137 // Can be blank "" or set to "duration" or "time" to display how long an event is.
138 ,showEventLength: "duration"
139
140 // Set which calendars for which to show events. Empty [] means all calendars.
141 ,selectCalendars: []
142
143 // Leave blank "" for no color, or specify shape (circle, rectangle) and/or side (left, right).
144 ,showCalendarColor: "rectangle left"
145
146 // When no events remain, show a hard-coded "message", a "greeting", or "none".
147 ,noEventBehavior: "message"
148}
149
150// SUNRISE
151// =======
152const sunriseSettings = {
153
154 // How many minutes before/after sunrise or sunset to show this element. 0 for always.
155 showWithin: 0
156}
157
158// WEATHER
159// =======
160const weatherSettings = {
161
162 // Set to imperial for Fahrenheit, or metric for Celsius
163 units: "metric"
164
165 // Show the location of the current weather.
166 ,showLocation: false
167
168 // Show the text description of the current conditions.
169 ,showCondition: false
170
171 // Show today's high and low temperatures.
172 ,showHighLow: true
173
174 // Set the hour (in 24-hour time) to switch to tomorrow's weather. Set to 24 to never show it.
175 ,tomorrowShownAtHour: 20
176}
177
178/*
179 * TEXT
180 * Change the language and formatting of text displayed.
181 * =====================================================
182 */
183
184// You can change the language or wording of any text in the widget.
185const localizedText = {
186
187 // The text shown if you add a greeting item to the layout.
188 nightGreeting: "Gute Nacht."
189 ,morningGreeting: "Guten Morgen."
190 ,afternoonGreeting: "Guten Nachmittag."
191 ,eveningGreeting: "Guten Abend."
192
193 // The text shown if you add a future weather item to the layout, or tomorrow's events.
194 ,nextHourLabel: "Nächste Stunde"
195 ,tomorrowLabel: "Morgen"
196
197 // Shown when noEventBehavior is set to "message".
198 ,noEventMessage: "Genieße den Rest des Tages."
199
200 // The text shown after the hours and minutes of an event duration.
201 ,durationMinute: "m"
202 ,durationHour: "h"
203
204 // The text shown after the remaining data volume
205 ,dataVolumeLeftLable: "verfügbar"
206
207}
208
209// 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.
210const textFormat = {
211
212 // Set the default font and color.
213 defaultText: { size: 12, color: "ffffff", font: "regular" },
214
215 // Any blank values will use the default.
216 smallDate: { size: 15, color: "", font: "semibold" },
217 largeDate1: { size: 28, color: "", font: "light" },
218 largeDate2: { size: 28, color: "", font: "light" },
219
220 greeting: { size: 28, color: "", font: "semibold" },
221 eventLabel: { size: 12, color: "", font: "semibold" },
222 eventTitle: { size: 12, color: "", font: "semibold" },
223 eventTime: { size: 12, color: "ffffffcc", font: "" },
224 noEvents: { size: 28, color: "", font: "semibold" },
225
226 largeTemp: { size: 32, color: "", font: "light" },
227 smallTemp: { size: 12, color: "", font: "" },
228 tinyTemp: { size: 10, color: "", font: "" },
229
230 customText: { size: 12, color: "", font: "" },
231
232 battery: { size: 12, color: "", font: "medium" },
233 sunrise: { size: 12, color: "", font: "medium" },
234 telekom: { size: 12, color: "", font: "medium" },
235
236}
237
238/*
239 * WIDGET CODE
240 * Be more careful editing this section.
241 * =====================================
242 */
243
244// Make sure we have a locale value.
245if (locale == "" || locale == null) { locale = Device.locale() }
246
247// Declare the data variables.
248var eventData, locationData, sunData, weatherData, telekomData
249
250// Create global constants.
251const currentDate = new Date()
252const files = FileManager.local()
253
254/*
255 * CONSTRUCTION
256 * ============
257 */
258
259// Set up the widget with padding.
260const widget = new ListWidget()
261const horizontalPad = padding < 10 ? 10 - padding : 10
262const verticalPad = padding < 15 ? 15 - padding : 15
263widget.setPadding(horizontalPad, verticalPad, horizontalPad, verticalPad)
264widget.spacing = 0
265
266// Set up the global variables.
267var currentRow = {}
268var currentColumn = {}
269
270// Set up the initial alignment.
271var currentAlignment = alignLeft
272
273// Set up the global ASCII variables.
274var currentColumns = []
275var rowNeedsSetup = false
276
277// It's ASCII time!
278if (typeof items[0] == 'string') {
279 for (line of items[0].split(/\r?\n/)) { await processLine(line) }
280}
281// Otherwise, set up normally.
282else {
283 for (item of items) { await item(currentColumn) }
284}
285
286/*
287 * BACKGROUND DISPLAY
288 * ==================
289 */
290
291// If it's an image background, display it.
292if (imageBackground) {
293
294 // Determine if our image exists and when it was saved.
295 const path = files.joinPath(files.documentsDirectory(), "weather-cal-image")
296 const exists = files.fileExists(path)
297
298 // If it exists and an update isn't forced, use the cache.
299 if (exists && (config.runsInWidget || !forceImageUpdate)) {
300 widget.backgroundImage = files.readImage(path)
301
302 // If it's missing when running in the widget, use a gray background.
303 } else if (!exists && config.runsInWidget) {
304 widget.backgroundColor = Color.gray()
305
306 // But if we're running in app, prompt the user for the image.
307 } else {
308 const img = await Photos.fromLibrary()
309 widget.backgroundImage = img
310 files.writeImage(path, img)
311 }
312
313// If it's not an image background, show the gradient.
314} else {
315 let gradient = new LinearGradient()
316 let gradientSettings = await setupGradient()
317
318 gradient.colors = gradientSettings.color()
319 gradient.locations = gradientSettings.position()
320
321 widget.backgroundGradient = gradient
322}
323
324// Finish the widget and show a preview.
325Script.setWidget(widget)
326if (widgetPreview == "small") { widget.presentSmall() }
327else if (widgetPreview == "medium") { widget.presentMedium() }
328else if (widgetPreview == "large") { widget.presentLarge() }
329Script.complete()
330
331/*
332 * ASCII FUNCTIONS
333 * Now isn't this a lot of fun?
334 * ============================
335 */
336
337// Provide the named function.
338function provideFunction(name) {
339 const functions = {
340 space() { return space },
341 left() { return left },
342 right() { return right },
343 center() { return center },
344 date() { return date },
345 greeting() { return greeting },
346 events() { return events },
347 current() { return current },
348 future() { return future },
349 battery() { return battery },
350 sunrise() { return sunrise },
351 telekom() { return telekom },
352 }
353 return functions[name]
354}
355
356// Processes a single line of ASCII.
357async function processLine(lineInput) {
358
359 // Because iOS loves adding periods to everything.
360 const line = lineInput.replace(/\.+/g,'')
361
362 // If it's blank, return.
363 if (line.trim() == '') { return }
364
365 // If it's a line, enumerate previous columns (if any) and set up the new row.
366 if (line[0] == '-' && line[line.length-1] == '-') {
367 if (currentColumns.length > 0) { await enumerateColumns() }
368 rowNeedsSetup = true
369 return
370 }
371
372 // If it's the first content row, finish the row setup.
373 if (rowNeedsSetup) {
374 row(currentColumn)
375 rowNeedsSetup = false
376 }
377
378 // If there's a number, this is a setup row.
379 const setupRow = line.match(/\d+/)
380
381 // Otherwise, it has columns.
382 const items = line.split('|')
383
384 // Iterate through each item.
385 for (var i=1; i < items.length-1; i++) {
386
387 // If the current column doesn't exist, make it.
388 if (!currentColumns[i]) { currentColumns[i] = { items: [] } }
389
390 // Now we have a column to add the items to.
391 const column = currentColumns[i].items
392
393 // Get the current item and its trimmed version.
394 const item = items[i]
395 const trim = item.trim()
396
397 // If it's not a function, figure out spacing.
398 if (!provideFunction(trim)) {
399
400 // If it's a setup row, whether or not we find the number, we keep going.
401 if (setupRow) {
402 const value = parseInt(trim, 10)
403 if (value) { currentColumns[i].width = value }
404 continue
405 }
406
407 // If it's blank and we haven't already added a space, add one.
408 const prevItem = column[column.length-1]
409 if (trim == '' && (!prevItem || (prevItem && !prevItem.startsWith("space")))) {
410 column.push("space")
411 }
412
413 // Either way, we're done.
414 continue
415
416 }
417
418 // Determine the alignment.
419 const index = item.indexOf(trim)
420 const length = item.slice(index,item.length).length
421
422 let align
423 if (index > 0 && length > trim.length) { align = "center" }
424 else if (index > 0) { align = "right" }
425 else { align = "left" }
426
427 // Add the items to the column.
428 column.push(align)
429 column.push(trim)
430 }
431}
432
433// Runs the function names in each column.
434async function enumerateColumns() {
435 if (currentColumns.length > 0) {
436 for (col of currentColumns) {
437
438 // If it's null, go to the next one.
439 if (!col) { continue }
440
441 // If there's a width, use the width function.
442 if (col.width) {
443 column(col.width)(currentColumn)
444
445 // Otherwise, create the column normally.
446 } else {
447 column(currentColumn)
448 }
449 for (item of col.items) {
450 const func = provideFunction(item)()
451 await func(currentColumn)
452 }
453 }
454 currentColumns = []
455 }
456}
457
458/*
459 * LAYOUT FUNCTIONS
460 * These functions manage spacing and alignment.
461 * =============================================
462 */
463
464// Makes a new row on the widget.
465function row(input = null) {
466
467 function makeRow() {
468 currentRow = widget.addStack()
469 currentRow.layoutHorizontally()
470 currentRow.setPadding(0, 0, 0, 0)
471 currentColumn.spacing = 0
472
473 // If input was given, make a column of that size.
474 if (input > 0) { currentRow.size = new Size(0,input) }
475 }
476
477 // If there's no input or it's a number, it's being called in the layout declaration.
478 if (!input || typeof input == "number") { return makeRow }
479
480 // Otherwise, it's being called in the generator.
481 else { makeRow() }
482}
483
484// Makes a new column on the widget.
485function column(input = null) {
486
487 function makeColumn() {
488 currentColumn = currentRow.addStack()
489 currentColumn.layoutVertically()
490 currentColumn.setPadding(0, 0, 0, 0)
491 currentColumn.spacing = 0
492
493 // If input was given, make a column of that size.
494 if (input > 0) { currentColumn.size = new Size(input,0) }
495 }
496
497 // If there's no input or it's a number, it's being called in the layout declaration.
498 if (!input || typeof input == "number") { return makeColumn }
499
500 // Otherwise, it's being called in the generator.
501 else { makeColumn() }
502}
503
504// Create an aligned stack to add content to.
505function align(column) {
506
507 // Add the containing stack to the column.
508 let alignmentStack = column.addStack()
509 alignmentStack.layoutHorizontally()
510
511 // Get the correct stack from the alignment function.
512 let returnStack = currentAlignment(alignmentStack)
513 returnStack.layoutVertically()
514 return returnStack
515}
516
517// Create a right-aligned stack.
518function alignRight(alignmentStack) {
519 alignmentStack.addSpacer()
520 let returnStack = alignmentStack.addStack()
521 return returnStack
522}
523
524// Create a left-aligned stack.
525function alignLeft(alignmentStack) {
526 let returnStack = alignmentStack.addStack()
527 alignmentStack.addSpacer()
528 return returnStack
529}
530
531// Create a center-aligned stack.
532function alignCenter(alignmentStack) {
533 alignmentStack.addSpacer()
534 let returnStack = alignmentStack.addStack()
535 alignmentStack.addSpacer()
536 return returnStack
537}
538
539// This function adds a space, with an optional amount.
540function space(input = null) {
541
542 // This function adds a spacer with the input width.
543 function spacer(column) {
544
545 // If the input is null or zero, add a flexible spacer.
546 if (!input || input == 0) { column.addSpacer() }
547
548 // Otherwise, add a space with the specified length.
549 else { column.addSpacer(input) }
550 }
551
552 // If there's no input or it's a number, it's being called in the column declaration.
553 if (!input || typeof input == "number") { return spacer }
554
555 // Otherwise, it's being called in the column generator.
556 else { input.addSpacer() }
557}
558
559// Change the current alignment to right.
560function right(x) { currentAlignment = alignRight }
561
562// Change the current alignment to left.
563function left(x) { currentAlignment = alignLeft }
564
565// Change the current alignment to center.
566function center(x) { currentAlignment = alignCenter }
567
568/*
569 * SETUP FUNCTIONS
570 * These functions prepare data needed for items.
571 * ==============================================
572 */
573
574// Set up the eventData object.
575async function setupEvents() {
576
577 eventData = {}
578 const calendars = eventSettings.selectCalendars
579 const numberOfEvents = eventSettings.numberOfEvents
580
581 // Function to determine if an event should be shown.
582 function shouldShowEvent(event) {
583
584 // If events are filtered and the calendar isn't in the selected calendars, return false.
585 if (calendars.length && !calendars.includes(event.calendar.title)) { return false }
586
587 // Hack to remove canceled Office 365 events.
588 if (event.title.startsWith("Canceled:")) { return false }
589
590 // If it's an all-day event, only show if the setting is active.
591 if (event.isAllDay) { return eventSettings.showAllDay }
592
593 // Otherwise, return the event if it's in the future.
594 return (event.startDate.getTime() > currentDate.getTime())
595 }
596
597 // Determine which events to show, and how many.
598 const todayEvents = await CalendarEvent.today([])
599 let shownEvents = 0
600 let futureEvents = []
601
602 for (const event of todayEvents) {
603 if (shownEvents == numberOfEvents) { break }
604 if (shouldShowEvent(event)) {
605 futureEvents.push(event)
606 shownEvents++
607 }
608 }
609
610 // If there's room and we need to, show tomorrow's events.
611 let multipleTomorrowEvents = false
612 if (eventSettings.showTomorrow && shownEvents < numberOfEvents) {
613
614 const tomorrowEvents = await CalendarEvent.tomorrow([])
615 for (const event of tomorrowEvents) {
616 if (shownEvents == numberOfEvents) { break }
617 if (shouldShowEvent(event)) {
618
619 // Add the tomorrow label prior to the first tomorrow event.
620 if (!multipleTomorrowEvents) {
621
622 // The tomorrow label is pretending to be an event.
623 futureEvents.push({ title: localizedText.tomorrowLabel.toUpperCase(), isLabel: true })
624 multipleTomorrowEvents = true
625 }
626
627 // Show the tomorrow event and increment the counter.
628 futureEvents.push(event)
629 shownEvents++
630 }
631 }
632 }
633
634 // Store the future events, and whether or not any events are displayed.
635 eventData.futureEvents = futureEvents
636 eventData.eventsAreVisible = (futureEvents.length > 0) && (eventSettings.numberOfEvents > 0)
637}
638
639// Set up the gradient for the widget background.
640async function setupGradient() {
641
642 // Requirements: sunrise
643 if (!sunData) { await setupSunrise() }
644
645 let gradient = {
646 dawn: {
647 color() { return [new Color("142C52"), new Color("1B416F"), new Color("62668B")] },
648 position() { return [0, 0.5, 1] },
649 },
650
651 sunrise: {
652 color() { return [new Color("274875"), new Color("766f8d"), new Color("f0b35e")] },
653 position() { return [0, 0.8, 1.5] },
654 },
655
656 midday: {
657 color() { return [new Color("3a8cc1"), new Color("90c0df")] },
658 position() { return [0, 1] },
659 },
660
661 noon: {
662 color() { return [new Color("b2d0e1"), new Color("80B5DB"), new Color("3a8cc1")] },
663 position() { return [-0.2, 0.2, 1.5] },
664 },
665
666 sunset: {
667 color() { return [new Color("32327A"), new Color("662E55"), new Color("7C2F43")] },
668 position() { return [0.1, 0.9, 1.2] },
669 },
670
671 twilight: {
672 color() { return [new Color("021033"), new Color("16296b"), new Color("414791")] },
673 position() { return [0, 0.5, 1] },
674 },
675
676 night: {
677 color() { return [new Color("16296b"), new Color("021033"), new Color("021033"), new Color("113245")] },
678 position() { return [-0.5, 0.2, 0.5, 1] },
679 },
680 }
681
682 const sunrise = sunData.sunrise
683 const sunset = sunData.sunset
684
685 // Use sunrise or sunset if we're within 30min of it.
686 if (closeTo(sunrise)<=15) { return gradient.sunrise }
687 if (closeTo(sunset)<=15) { return gradient.sunset }
688
689 // In the 30min before/after, use dawn/twilight.
690 if (closeTo(sunrise)<=45 && currentDate.getTime() < sunrise) { return gradient.dawn }
691 if (closeTo(sunset)<=45 && currentDate.getTime() > sunset) { return gradient.twilight }
692
693 // Otherwise, if it's night, return night.
694 if (isNight(currentDate)) { return gradient.night }
695
696 // If it's around noon, the sun is high in the sky.
697 if (currentDate.getHours() == 12) { return gradient.noon }
698
699 // Otherwise, return the "typical" theme.
700 return gradient.midday
701}
702
703// Set up the locationData object.
704async function setupLocation() {
705
706 locationData = {}
707 const locationPath = files.joinPath(files.documentsDirectory(), "weather-cal-loc")
708
709 // If our location is unlocked or cache doesn't exist, ask iOS for location.
710 var readLocationFromFile = false
711 if (!lockLocation || !files.fileExists(locationPath)) {
712 try {
713 const location = await Location.current()
714 const geocode = await Location.reverseGeocode(location.latitude, location.longitude, locale)
715 locationData.latitude = location.latitude
716 locationData.longitude = location.longitude
717 locationData.locality = geocode[0].locality
718 files.writeString(locationPath, location.latitude + "|" + location.longitude + "|" + locationData.locality)
719
720 } catch(e) {
721 // If we fail in unlocked mode, read it from the cache.
722 if (!lockLocation) { readLocationFromFile = true }
723
724 // We can't recover if we fail on first run in locked mode.
725 else { return }
726 }
727 }
728
729 // If our location is locked or we need to read from file, do it.
730 if (lockLocation || readLocationFromFile) {
731 const locationStr = files.readString(locationPath).split("|")
732 locationData.latitude = locationStr[0]
733 locationData.longitude = locationStr[1]
734 locationData.locality = locationStr[2]
735 }
736}
737
738// Set up the sunData object.
739async function setupSunrise() {
740
741 // Requirements: location
742 if (!locationData) { await setupLocation() }
743
744 async function getSunData(date) {
745 const req = "https://api.sunrise-sunset.org/json?lat=" + locationData.latitude + "&lng=" + locationData.longitude + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth()+1) + "-" + date.getDate()
746 const data = await new Request(req).loadJSON()
747 return data
748 }
749
750 // Set up the sunrise/sunset cache.
751 const sunCachePath = files.joinPath(files.documentsDirectory(), "weather-cal-sunrise")
752 const sunCacheExists = files.fileExists(sunCachePath)
753 const sunCacheDate = sunCacheExists ? files.modificationDate(sunCachePath) : 0
754 let sunDataRaw
755
756 // If cache exists and was created today, use cached data.
757 if (sunCacheExists && sameDay(currentDate, sunCacheDate)) {
758 const sunCache = files.readString(sunCachePath)
759 sunDataRaw = JSON.parse(sunCache)
760 }
761
762 // Otherwise, get the data from the server.
763 else {
764
765 sunDataRaw = await getSunData(currentDate)
766
767 // Calculate tomorrow's date and get tomorrow's data.
768 let tomorrowDate = new Date()
769 tomorrowDate.setDate(currentDate.getDate() + 1)
770 const tomorrowData = await getSunData(tomorrowDate)
771 sunDataRaw.results.tomorrow = tomorrowData.results.sunrise
772
773 // Cache the file.
774 files.writeString(sunCachePath, JSON.stringify(sunDataRaw))
775 }
776
777 // Store the timing values.
778 sunData = {}
779 sunData.sunrise = new Date(sunDataRaw.results.sunrise).getTime()
780 sunData.sunset = new Date(sunDataRaw.results.sunset).getTime()
781 sunData.tomorrow = new Date(sunDataRaw.results.tomorrow).getTime()
782}
783
784// Set up the weatherData object.
785async function setupWeather() {
786
787 // Requirements: location
788 if (!locationData) { await setupLocation() }
789
790 // Set up the cache.
791 const cachePath = files.joinPath(files.documentsDirectory(), "weather-cal-cache")
792 const cacheExists = files.fileExists(cachePath)
793 const cacheDate = cacheExists ? files.modificationDate(cachePath) : 0
794 var weatherDataRaw
795
796 // If cache exists and it's been less than 60 seconds since last request, use cached data.
797 if (cacheExists && (currentDate.getTime() - cacheDate.getTime()) < 60000) {
798 const cache = files.readString(cachePath)
799 weatherDataRaw = JSON.parse(cache)
800
801 // Otherwise, use the API to get new weather data.
802 } else {
803 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
804 weatherDataRaw = await new Request(weatherReq).loadJSON()
805 files.writeString(cachePath, JSON.stringify(weatherDataRaw))
806 }
807
808 // Store the weather values.
809 weatherData = {}
810 weatherData.currentTemp = weatherDataRaw.current.temp
811 weatherData.currentCondition = weatherDataRaw.current.weather[0].id
812 weatherData.currentDescription = weatherDataRaw.current.weather[0].description
813 weatherData.todayHigh = weatherDataRaw.daily[0].temp.max
814 weatherData.todayLow = weatherDataRaw.daily[0].temp.min
815
816 weatherData.nextHourTemp = weatherDataRaw.hourly[1].temp
817 weatherData.nextHourCondition = weatherDataRaw.hourly[1].weather[0].id
818
819 weatherData.tomorrowHigh = weatherDataRaw.daily[1].temp.max
820 weatherData.tomorrowLow = weatherDataRaw.daily[1].temp.min
821 weatherData.tomorrowCondition = weatherDataRaw.daily[1].weather[0].id
822}
823
824async function setupTelekom() {
825
826 const telekomApiUrl = "https://pass.telekom.de/api/service/generic/v1/status"
827
828 let fm = FileManager.local()
829 let docDir = fm.documentsDirectory()
830 let jsonLocalPathTelekom = fm.joinPath(docDir, "scriptable-telekom.json")
831 let lastFetchDateLocalPathTelekom = fm.joinPath(docDir, "lastUpdateTelekom.txt")
832
833 let telekomRequest = new Request(telekomApiUrl)
834
835 // API only answers for mobile Safari
836 telekomRequest.headers = {
837 "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1"
838 }
839
840 let telekomData = false, telekomApi_online = false, lastFetchDateTelekom = false
841
842 try {
843
844 // Fetch data from pass.telekom.de
845 telekomData = await telekomRequest.loadJSON()
846
847 // Write JSON to local file
848
849 fm.writeString(jsonLocalPathTelekom, JSON.stringify(telekomData, null, 2))
850 telekomApi_online = true
851 lastFetchDateTelekom = new Date()
852 fm.writeString(lastFetchDateLocalPathTelekom, lastFetchDateTelekom.toString())
853
854 return {
855 telekomData: telekomData,
856 telekomApi_online: telekomApi_online,
857 lastFetchDateTelekom: lastFetchDateTelekom
858 }
859
860 } catch (err) {
861
862 // Read data from local file
863 if (fm.fileExists(jsonLocalPathTelekom) && fm.fileExists(lastFetchDateLocalPathTelekom)) {
864
865 telekomData = JSON.parse(fm.readString(jsonLocalPathTelekom), null)
866 lastFetchDateTelekom = new Date(fm.readString(lastFetchDateLocalPathTelekom, null))
867
868 return {
869 telekomData: telekomData,
870 telekomApi_online: telekomApi_online,
871 lastFetchDateTelekom: lastFetchDateTelekom
872 }
873
874 } else {
875
876 return {
877 telekomData: telekomData,
878 telekomApi_online: telekomApi_online,
879 lastFetchDateTelekom: lastFetchDateTelekom
880 }
881
882 }
883
884 }
885
886}
887
888/*
889 * WIDGET ITEMS
890 * These functions display items on the widget.
891 * ============================================
892 */
893
894// Display the date on the widget.
895async function date(column) {
896
897 // Requirements: events (if dynamicDateSize is enabled)
898 if (!eventData && dateSettings.dynamicDateSize) { await setupEvents() }
899
900 // Set up the date formatter and set its locale.
901 let df = new DateFormatter()
902 df.locale = locale
903
904 // Show small if it's hard coded, or if it's dynamic and events are visible.
905 if (dateSettings.staticDateSize == "small" || (dateSettings.dynamicDateSize && eventData.eventsAreVisible)) {
906 let dateStack = align(column)
907 dateStack.setPadding(padding, padding, padding, padding)
908
909 df.dateFormat = dateSettings.smallDateFormat
910 let dateText = provideText(df.string(currentDate), dateStack, textFormat.smallDate)
911
912 // Otherwise, show the large date.
913 } else {
914 let dateOneStack = align(column)
915 df.dateFormat = dateSettings.largeDateLineOne
916 let dateOne = provideText(df.string(currentDate), dateOneStack, textFormat.largeDate1)
917 dateOneStack.setPadding(padding/2, padding, 0, padding)
918
919 let dateTwoStack = align(column)
920 df.dateFormat = dateSettings.largeDateLineTwo
921 let dateTwo = provideText(df.string(currentDate), dateTwoStack, textFormat.largeDate2)
922 dateTwoStack.setPadding(0, padding, padding, padding)
923 }
924}
925
926// Display a time-based greeting on the widget.
927async function greeting(column) {
928
929 // This function makes a greeting based on the time of day.
930 function makeGreeting() {
931 const hour = currentDate.getHours()
932 if (hour < 5) { return localizedText.nightGreeting }
933 if (hour < 12) { return localizedText.morningGreeting }
934 if (hour-12 < 5) { return localizedText.afternoonGreeting }
935 if (hour-12 < 10) { return localizedText.eveningGreeting }
936 return localizedText.nightGreeting
937 }
938
939 // Set up the greeting.
940 let greetingStack = align(column)
941 let greeting = provideText(makeGreeting(), greetingStack, textFormat.greeting)
942 greetingStack.setPadding(padding, padding, padding, padding)
943}
944
945// Display events on the widget.
946async function events(column) {
947
948 // Requirements: events
949 if (!eventData) { await setupEvents() }
950
951 // If no events are visible, figure out what to do.
952 if (!eventData.eventsAreVisible) {
953 const display = eventSettings.noEventBehavior
954
955 // If it's a greeting, let the greeting function handle it.
956 if (display == "greeting") { return await greeting(column) }
957
958 // If it's a message, get the localized text.
959 if (display == "message" && localizedText.noEventMessage.length) {
960 const messageStack = align(column)
961 messageStack.setPadding(padding, padding, padding, padding)
962 provideText(localizedText.noEventMessage, messageStack, textFormat.noEvents)
963 }
964
965 // Whether or not we displayed something, return here.
966 return
967 }
968
969 // Set up the event stack.
970 let eventStack = column.addStack()
971 eventStack.layoutVertically()
972 const todaySeconds = Math.floor(currentDate.getTime() / 1000) - 978307200
973 eventStack.url = 'calshow:' + todaySeconds
974
975 // If there are no events and we have a message, show it and return.
976 if (!eventData.eventsAreVisible && localizedText.noEventMessage.length) {
977 let message = provideText(localizedText.noEventMessage, eventStack, textFormat.noEvents)
978 eventStack.setPadding(padding, padding, padding, padding)
979 return
980 }
981
982 // If we're not showing the message, don't pad the event stack.
983 eventStack.setPadding(0, 0, 0, 0)
984
985 // Add each event to the stack.
986 var currentStack = eventStack
987 const futureEvents = eventData.futureEvents
988 for (let i = 0; i < futureEvents.length; i++) {
989
990 const event = futureEvents[i]
991 const bottomPadding = (padding-10 < 0) ? 0 : padding-10
992
993 // If it's the tomorrow label, change to the tomorrow stack.
994 if (event.isLabel) {
995 let tomorrowStack = column.addStack()
996 tomorrowStack.layoutVertically()
997 const tomorrowSeconds = Math.floor(currentDate.getTime() / 1000) - 978220800
998 tomorrowStack.url = 'calshow:' + tomorrowSeconds
999 currentStack = tomorrowStack
1000
1001 // Mimic the formatting of an event title, mostly.
1002 const eventLabelStack = align(currentStack)
1003 const eventLabel = provideText(event.title, eventLabelStack, textFormat.eventLabel)
1004 eventLabelStack.setPadding(padding, padding, padding, padding)
1005 continue
1006 }
1007
1008 const titleStack = align(currentStack)
1009 titleStack.layoutHorizontally()
1010 const showCalendarColor = eventSettings.showCalendarColor
1011 const colorShape = showCalendarColor.includes("circle") ? "circle" : "rectangle"
1012
1013 // If we're showing a color, and it's not shown on the right, add it to the left.
1014 if (showCalendarColor.length && !showCalendarColor.includes("right")) {
1015 let colorItemText = provideTextSymbol(colorShape) + " "
1016 let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
1017 colorItem.textColor = event.calendar.color
1018 }
1019
1020 const title = provideText(event.title.trim(), titleStack, textFormat.eventTitle)
1021 titleStack.setPadding(padding, padding, event.isAllDay ? padding : padding/5, padding)
1022
1023 // If we're showing a color on the right, show it.
1024 if (showCalendarColor.length && showCalendarColor.includes("right")) {
1025 let colorItemText = " " + provideTextSymbol(colorShape)
1026 let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
1027 colorItem.textColor = event.calendar.color
1028 }
1029
1030 // If there are too many events, limit the line height.
1031 if (futureEvents.length >= 3) { title.lineLimit = 1 }
1032
1033 // If it's an all-day event, we don't need a time.
1034 if (event.isAllDay) { continue }
1035
1036 // Format the time information.
1037 let timeText = formatTime(event.startDate)
1038
1039 // If we show the length as time, add an en dash and the time.
1040 if (eventSettings.showEventLength == "time") {
1041 timeText += "–" + formatTime(event.endDate)
1042
1043 // If we should it as a duration, add the minutes.
1044 } else if (eventSettings.showEventLength == "duration") {
1045 const duration = (event.endDate.getTime() - event.startDate.getTime()) / (1000*60)
1046 const hours = Math.floor(duration/60)
1047 const minutes = Math.floor(duration % 60)
1048 const hourText = hours>0 ? hours + localizedText.durationHour : ""
1049 const minuteText = minutes>0 ? minutes + localizedText.durationMinute : ""
1050 const showSpace = hourText.length && minuteText.length
1051 timeText += " \u2022 " + hourText + (showSpace ? " " : "") + minuteText
1052 }
1053
1054 const timeStack = align(currentStack)
1055 const time = provideText(timeText, timeStack, textFormat.eventTime)
1056 timeStack.setPadding(0, padding, padding, padding)
1057 }
1058}
1059
1060// Display the current weather.
1061async function current(column) {
1062
1063 // Requirements: weather and sunrise
1064 if (!weatherData) { await setupWeather() }
1065 if (!sunData) { await setupSunrise() }
1066
1067 // Set up the current weather stack.
1068 let currentWeatherStack = column.addStack()
1069 currentWeatherStack.layoutVertically()
1070 currentWeatherStack.setPadding(0, 0, 0, 0)
1071 currentWeatherStack.url = "https://weather.com/weather/today/l/" + locationData.latitude + "," + locationData.longitude
1072
1073 // If we're showing the location, add it.
1074 if (weatherSettings.showLocation) {
1075 let locationTextStack = align(currentWeatherStack)
1076 let locationText = provideText(locationData.locality, locationTextStack, textFormat.smallTemp)
1077 locationTextStack.setPadding(padding, padding, padding, padding)
1078 }
1079
1080 // Show the current condition symbol.
1081 let mainConditionStack = align(currentWeatherStack)
1082 let mainCondition = mainConditionStack.addImage(provideConditionSymbol(weatherData.currentCondition,isNight(currentDate)))
1083 mainCondition.imageSize = new Size(22,22)
1084 tintIcon(mainCondition, textFormat.largeTemp)
1085 mainConditionStack.setPadding(weatherSettings.showLocation ? 0 : padding, padding, 0, padding)
1086
1087 // If we're showing the description, add it.
1088 if (weatherSettings.showCondition) {
1089 let conditionTextStack = align(currentWeatherStack)
1090 let conditionText = provideText(weatherData.currentDescription, conditionTextStack, textFormat.smallTemp)
1091 conditionTextStack.setPadding(padding, padding, 0, padding)
1092 }
1093
1094 // Show the current temperature.
1095 const tempStack = align(currentWeatherStack)
1096 tempStack.setPadding(0, padding, 0, padding)
1097 const tempText = Math.round(weatherData.currentTemp) + "°"
1098 const temp = provideText(tempText, tempStack, textFormat.largeTemp)
1099
1100 // If we're not showing the high and low, end it here.
1101 if (!weatherSettings.showHighLow) { return }
1102
1103 // Show the temp bar and high/low values.
1104 let tempBarStack = align(currentWeatherStack)
1105 tempBarStack.layoutVertically()
1106 tempBarStack.setPadding(0, padding, padding, padding)
1107
1108 let tempBar = drawTempBar()
1109 let tempBarImage = tempBarStack.addImage(tempBar)
1110 tempBarImage.size = new Size(50,0)
1111
1112 tempBarStack.addSpacer(1)
1113
1114 let highLowStack = tempBarStack.addStack()
1115 highLowStack.layoutHorizontally()
1116
1117 const mainLowText = Math.round(weatherData.todayLow).toString()
1118 const mainLow = provideText(mainLowText, highLowStack, textFormat.tinyTemp)
1119 highLowStack.addSpacer()
1120 const mainHighText = Math.round(weatherData.todayHigh).toString()
1121 const mainHigh = provideText(mainHighText, highLowStack, textFormat.tinyTemp)
1122
1123 tempBarStack.size = new Size(60,30)
1124}
1125
1126// Display upcoming weather.
1127async function future(column) {
1128
1129 // Requirements: weather and sunrise
1130 if (!weatherData) { await setupWeather() }
1131 if (!sunData) { await setupSunrise() }
1132
1133 // Set up the future weather stack.
1134 let futureWeatherStack = column.addStack()
1135 futureWeatherStack.layoutVertically()
1136 futureWeatherStack.setPadding(0, 0, 0, 0)
1137 futureWeatherStack.url = "https://weather.com/weather/tenday/l/" + locationData.latitude + "," + locationData.longitude
1138
1139 // Determine if we should show the next hour.
1140 const showNextHour = (currentDate.getHours() < weatherSettings.tomorrowShownAtHour)
1141
1142 // Set the label value.
1143 const subLabelStack = align(futureWeatherStack)
1144 const subLabelText = showNextHour ? localizedText.nextHourLabel : localizedText.tomorrowLabel
1145 const subLabel = provideText(subLabelText, subLabelStack, textFormat.smallTemp)
1146 subLabelStack.setPadding(0, padding, padding/2, padding)
1147
1148 // Set up the sub condition stack.
1149 let subConditionStack = align(futureWeatherStack)
1150 subConditionStack.layoutHorizontally()
1151 subConditionStack.centerAlignContent()
1152 subConditionStack.setPadding(0, padding, padding, padding)
1153
1154 // Determine if it will be night in the next hour.
1155 var nightCondition
1156 if (showNextHour) {
1157 const addHour = currentDate.getTime() + (60*60*1000)
1158 const newDate = new Date(addHour)
1159 nightCondition = isNight(newDate)
1160 } else {
1161 nightCondition = false
1162 }
1163
1164 let subCondition = subConditionStack.addImage(provideConditionSymbol(showNextHour ? weatherData.nextHourCondition : weatherData.tomorrowCondition,nightCondition))
1165 const subConditionSize = showNextHour ? 14 : 18
1166 subCondition.imageSize = new Size(subConditionSize, subConditionSize)
1167 tintIcon(subCondition, textFormat.smallTemp)
1168 subConditionStack.addSpacer(5)
1169
1170 // The next part of the display changes significantly for next hour vs tomorrow.
1171 if (showNextHour) {
1172 const subTempText = Math.round(weatherData.nextHourTemp) + "°"
1173 const subTemp = provideText(subTempText, subConditionStack, textFormat.smallTemp)
1174
1175 } else {
1176 let tomorrowLine = subConditionStack.addImage(drawVerticalLine(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5), 20))
1177 tomorrowLine.imageSize = new Size(3,28)
1178 subConditionStack.addSpacer(5)
1179 let tomorrowStack = subConditionStack.addStack()
1180 tomorrowStack.layoutVertically()
1181
1182 const tomorrowHighText = Math.round(weatherData.tomorrowHigh) + ""
1183 const tomorrowHigh = provideText(tomorrowHighText, tomorrowStack, textFormat.tinyTemp)
1184 tomorrowStack.addSpacer(4)
1185 const tomorrowLowText = Math.round(weatherData.tomorrowLow) + ""
1186 const tomorrowLow = provideText(tomorrowLowText, tomorrowStack, textFormat.tinyTemp)
1187 }
1188}
1189
1190// Return a text-creation function.
1191function text(input = null) {
1192
1193 function displayText(column) {
1194
1195 // Don't do anything if the input is blank.
1196 if (!input || input == "") { return }
1197
1198 // Otherwise, add the text.
1199 const textStack = align(column)
1200 textStack.setPadding(padding, padding, padding, padding)
1201 const textDisplay = provideText(input, textStack, textFormat.customText)
1202 }
1203 return displayText
1204}
1205
1206// Add a battery element to the widget; consisting of a battery icon and percentage.
1207async function battery(column) {
1208
1209 // Get battery level via Scriptable function and format it in a convenient way
1210 function getBatteryLevel() {
1211
1212 const batteryLevel = Device.batteryLevel()
1213 const batteryPercentage = `${Math.round(batteryLevel * 100)}%`
1214
1215 return batteryPercentage
1216 }
1217
1218 const batteryLevel = Device.batteryLevel()
1219
1220 // Set up the battery level item
1221 let batteryStack = align(column)
1222 batteryStack.layoutHorizontally()
1223 batteryStack.centerAlignContent()
1224
1225 let batteryIcon = batteryStack.addImage(provideBatteryIcon())
1226 batteryIcon.imageSize = new Size(30,30)
1227
1228 // Change the battery icon to red if battery level is <= 20 to match system behavior
1229 if ( Math.round(batteryLevel * 100) > 20 || Device.isCharging() ) {
1230
1231 tintIcon(batteryIcon, textFormat.battery)
1232
1233 } else {
1234
1235 batteryIcon.tintColor = Color.red()
1236
1237 }
1238
1239 batteryStack.addSpacer(padding * 0.6)
1240
1241 // Display the battery status
1242 let batteryInfo = provideText(getBatteryLevel(), batteryStack, textFormat.battery)
1243
1244 batteryStack.setPadding(padding/2, padding, padding/2, padding)
1245
1246}
1247
1248// Show the sunrise or sunset time.
1249async function sunrise(column) {
1250
1251 // Requirements: sunrise
1252 if (!sunData) { await setupSunrise() }
1253
1254 const sunrise = sunData.sunrise
1255 const sunset = sunData.sunset
1256 const tomorrow = sunData.tomorrow
1257 const current = currentDate.getTime()
1258
1259 const showWithin = sunriseSettings.showWithin
1260 const closeToSunrise = closeTo(sunrise) <= showWithin
1261 const closeToSunset = closeTo(sunset) <= showWithin
1262
1263 // If we only show sometimes and we're not close, return.
1264 if (showWithin > 0 && !closeToSunrise && !closeToSunset) { return }
1265
1266 // Otherwise, determine which time to show.
1267 let timeToShow, symbolName
1268 const halfHour = 30 * 60 * 1000
1269
1270 // If we're between sunrise and sunset, show the sunset.
1271 if (current > sunrise + halfHour && current < sunset + halfHour) {
1272 symbolName = "sunset.fill"
1273 timeToShow = sunset
1274 }
1275
1276 // Otherwise, show a sunrise.
1277 else {
1278 symbolName = "sunrise.fill"
1279 timeToShow = current > sunset ? tomorrow : sunrise
1280 }
1281
1282 // Set up the stack.
1283 const sunriseStack = align(column)
1284 sunriseStack.setPadding(padding/2, padding, padding/2, padding)
1285 sunriseStack.layoutHorizontally()
1286 sunriseStack.centerAlignContent()
1287
1288 sunriseStack.addSpacer(padding * 0.3)
1289
1290 // Add the correct symbol.
1291 const symbol = sunriseStack.addImage(SFSymbol.named(symbolName).image)
1292 symbol.imageSize = new Size(22,22)
1293 tintIcon(symbol, textFormat.sunrise)
1294
1295 sunriseStack.addSpacer(padding)
1296
1297 // Add the time.
1298 const timeText = formatTime(new Date(timeToShow))
1299 const time = provideText(timeText, sunriseStack, textFormat.sunrise)
1300}
1301
1302// Allow for either term to be used.
1303async function sunset(column) {
1304 return await sunrise(column)
1305}
1306
1307async function telekom(column) {
1308
1309 // Requirements: telekom
1310 if (!telekomData) {
1311 telekomData = await setupTelekom()
1312 }
1313
1314 // Set up the telekom stack
1315 let telekomStack = align(column)
1316 telekomStack.layoutHorizontally()
1317 telekomStack.centerAlignContent()
1318
1319 // Icon
1320 let telekomIcon = SFSymbol.named('antenna.radiowaves.left.and.right')
1321 let telekomIconElement = telekomStack.addImage(telekomIcon.image)
1322 telekomIconElement.imageSize = new Size(15, 15)
1323 telekomIconElement.tintColor = Color.green()
1324 if (!telekomData.telekomApi_online) {
1325 telekomIconElement.tintColor = Color.red()
1326 }
1327 telekomStack.addSpacer(4)
1328
1329 let telekomText = "keine Daten gespeichert"
1330
1331 if (telekomData.telekomData) {
1332
1333 // Usage bullet
1334 let bullet = telekomStack.addText("●")
1335 bullet.font = Font.heavySystemFont(14)
1336 bullet.textColor = Color.green()
1337
1338 if (telekomData.telekomData.usedPercentage >= 75) {
1339
1340 bullet.textColor = Color.orange()
1341
1342 } else if (telekomData.telekomData.usedPercentage >= 90) {
1343
1344 bullet.textColor = Color.red()
1345
1346 }
1347
1348 let volLeft = 0
1349 let byteLeft = telekomData.telekomData.initialVolume - telekomData.telekomData.usedVolume
1350 let kbLeft = byteLeft / 1024
1351 let mbLeft = kbLeft / 1024
1352 let gbLeft = mbLeft / 1024
1353
1354 if (gbLeft >= 1) {
1355 volLeft = gbLeft.toFixed(1)
1356 telekomText = volLeft + " GB"
1357 } else if (mbLeft >= 1) {
1358 volLeft = mbLeft.toFixed(1)
1359 telekomText = volLeft + " MB"
1360 } else if (kbLeft >= 1) {
1361 volLeft = kbLeft.toFixed(1)
1362 telekomText = volLeft + " KB"
1363 } else {
1364 volLeft = byteLeft
1365 telekomText = volLeft + " Byte"
1366 }
1367
1368 }
1369
1370 telekomStack.addSpacer(4)
1371
1372 telekomText = telekomText + " " + localizedText.dataVolumeLeftLable
1373
1374 const datenverbrauch = provideText(telekomText, telekomStack, textFormat.telekom)
1375
1376}
1377
1378/*
1379 * HELPER FUNCTIONS
1380 * These functions perform duties for other functions.
1381 * ===================================================
1382 */
1383
1384// Tints icons if needed.
1385function tintIcon(icon,format) {
1386 if (!tintIcons) { return }
1387 icon.tintColor = new Color(format.color || textFormat.defaultText.color)
1388}
1389
1390// Determines if the provided date is at night.
1391function isNight(dateInput) {
1392 const timeValue = dateInput.getTime()
1393 return (timeValue < sunData.sunrise) || (timeValue > sunData.sunset)
1394}
1395
1396// Determines if two dates occur on the same day
1397function sameDay(d1, d2) {
1398 return d1.getFullYear() === d2.getFullYear() &&
1399 d1.getMonth() === d2.getMonth() &&
1400 d1.getDate() === d2.getDate()
1401}
1402
1403// Returns the number of minutes between now and the provided date.
1404function closeTo(time) {
1405 return Math.abs(currentDate.getTime() - time) / 60000
1406}
1407
1408// Format the time for a Date input.
1409function formatTime(date) {
1410 let df = new DateFormatter()
1411 df.locale = locale
1412 df.useNoDateStyle()
1413 df.useShortTimeStyle()
1414 return df.string(date)
1415}
1416
1417// Provide a text symbol with the specified shape.
1418function provideTextSymbol(shape) {
1419
1420 // Rectangle character.
1421 if (shape.startsWith("rect")) {
1422 return "\u2759"
1423 }
1424 // Circle character.
1425 if (shape == "circle") {
1426 return "\u2B24"
1427 }
1428 // Default to the rectangle.
1429 return "\u2759"
1430}
1431
1432// Provide a battery SFSymbol with accurate level drawn on top of it.
1433function provideBatteryIcon() {
1434
1435 // If we're charging, show the charging icon.
1436 if (Device.isCharging()) { return SFSymbol.named("battery.100.bolt").image }
1437
1438 // Set the size of the battery icon.
1439 const batteryWidth = 87
1440 const batteryHeight = 41
1441
1442 // Start our draw context.
1443 let draw = new DrawContext()
1444 draw.opaque = false
1445 draw.respectScreenScale = true
1446 draw.size = new Size(batteryWidth, batteryHeight)
1447
1448 // Draw the battery.
1449 draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
1450
1451 // Match the battery level values to the SFSymbol.
1452 const x = batteryWidth*0.1525
1453 const y = batteryHeight*0.247
1454 const width = batteryWidth*0.602
1455 const height = batteryHeight*0.505
1456
1457 // Prevent unreadable icons.
1458 let level = Device.batteryLevel()
1459 if (level < 0.05) { level = 0.05 }
1460
1461 // Determine the width and radius of the battery level.
1462 const current = width * level
1463 let radius = height/6.5
1464
1465 // When it gets low, adjust the radius to match.
1466 if (current < (radius * 2)) { radius = current / 2 }
1467
1468 // Make the path for the battery level.
1469 let barPath = new Path()
1470 barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
1471 draw.addPath(barPath)
1472 const color = tintIcons ? (textFormat.battery.color || textFormat.defaultText.color) : "000000"
1473 draw.setFillColor(new Color(color))
1474 draw.fillPath()
1475 return draw.getImage()
1476}
1477
1478// Provide a symbol based on the condition.
1479function provideConditionSymbol(cond,night) {
1480
1481 // Define our symbol equivalencies.
1482 let symbols = {
1483
1484 // Thunderstorm
1485 "2": function() { return "cloud.bolt.rain.fill" },
1486
1487 // Drizzle
1488 "3": function() { return "cloud.drizzle.fill" },
1489
1490 // Rain
1491 "5": function() { return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
1492
1493 // Snow
1494 "6": function() { return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
1495
1496 // Atmosphere
1497 "7": function() {
1498 if (cond == 781) { return "tornado" }
1499 if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
1500 return night ? "cloud.fog.fill" : "sun.haze.fill"
1501 },
1502
1503 // Clear and clouds
1504 "8": function() {
1505 if (cond == 800 || cond == 801) { return night ? "moon.stars.fill" : "sun.max.fill" }
1506 if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" }
1507 return "cloud.fill"
1508 }
1509 }
1510
1511 // Find out the first digit.
1512 let conditionDigit = Math.floor(cond / 100)
1513
1514 // Get the symbol.
1515 return SFSymbol.named(symbols[conditionDigit]()).image
1516}
1517
1518// Provide a font based on the input.
1519function provideFont(fontName, fontSize) {
1520 const fontGenerator = {
1521 "ultralight": function() { return Font.ultraLightSystemFont(fontSize) },
1522 "light": function() { return Font.lightSystemFont(fontSize) },
1523 "regular": function() { return Font.regularSystemFont(fontSize) },
1524 "medium": function() { return Font.mediumSystemFont(fontSize) },
1525 "semibold": function() { return Font.semiboldSystemFont(fontSize) },
1526 "bold": function() { return Font.boldSystemFont(fontSize) },
1527 "heavy": function() { return Font.heavySystemFont(fontSize) },
1528 "black": function() { return Font.blackSystemFont(fontSize) },
1529 "italic": function() { return Font.italicSystemFont(fontSize) }
1530 }
1531
1532 const systemFont = fontGenerator[fontName]
1533 if (systemFont) { return systemFont() }
1534 return new Font(fontName, fontSize)
1535}
1536
1537// Add formatted text to a container.
1538function provideText(string, container, format) {
1539 const textItem = container.addText(string)
1540 const textFont = format.font || textFormat.defaultText.font
1541 const textSize = format.size || textFormat.defaultText.size
1542 const textColor = format.color || textFormat.defaultText.color
1543
1544 textItem.font = provideFont(textFont, textSize)
1545 textItem.textColor = new Color(textColor)
1546 return textItem
1547}
1548
1549/*
1550 * DRAWING FUNCTIONS
1551 * These functions draw onto a canvas.
1552 * ===================================
1553 */
1554
1555// Draw the vertical line in the tomorrow view.
1556function drawVerticalLine(color, height) {
1557
1558 const width = 2
1559
1560 let draw = new DrawContext()
1561 draw.opaque = false
1562 draw.respectScreenScale = true
1563 draw.size = new Size(width,height)
1564
1565 let barPath = new Path()
1566 const barHeight = height
1567 barPath.addRoundedRect(new Rect(0, 0, width, height), width/2, width/2)
1568 draw.addPath(barPath)
1569 draw.setFillColor(color)
1570 draw.fillPath()
1571
1572 return draw.getImage()
1573}
1574
1575// Draw the temp bar.
1576function drawTempBar() {
1577
1578 // Set the size of the temp bar.
1579 const tempBarWidth = 200
1580 const tempBarHeight = 20
1581
1582 // Calculate the current percentage of the high-low range.
1583 let percent = (weatherData.currentTemp - weatherData.todayLow) / (weatherData.todayHigh - weatherData.todayLow)
1584
1585 // If we're out of bounds, clip it.
1586 if (percent < 0) {
1587 percent = 0
1588 } else if (percent > 1) {
1589 percent = 1
1590 }
1591
1592 // Determine the scaled x-value for the current temp.
1593 const currPosition = (tempBarWidth - tempBarHeight) * percent
1594
1595 // Start our draw context.
1596 let draw = new DrawContext()
1597 draw.opaque = false
1598 draw.respectScreenScale = true
1599 draw.size = new Size(tempBarWidth, tempBarHeight)
1600
1601 // Make the path for the bar.
1602 let barPath = new Path()
1603 const barHeight = tempBarHeight - 10
1604 barPath.addRoundedRect(new Rect(0, 5, tempBarWidth, barHeight), barHeight / 2, barHeight / 2)
1605 draw.addPath(barPath)
1606
1607 // Determine the color.
1608 const barColor = textFormat.battery.color || textFormat.defaultText.color
1609 draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5))
1610 draw.fillPath()
1611
1612 // Make the path for the current temp indicator.
1613 let currPath = new Path()
1614 currPath.addEllipse(new Rect(currPosition, 0, tempBarHeight, tempBarHeight))
1615 draw.addPath(currPath)
1616 draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 1))
1617 draw.fillPath()
1618
1619 return draw.getImage()
1620}
1621