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