· 5 years ago · Nov 29, 2020, 09:48 AM
1
2/*
3
4~
5
6This is the Weather Cal code script.
7Don't delete it or change its name.
8
9To update, run a Weather Cal widget script.
10In the popup, tap "Update code".
11It will update to the newest version.
12
13~
14
15*/
16
17const weatherCal = {
18
19 // Initialize shared object properties.
20 initialize(name, iCloudInUse) {
21 this.name = name
22 this.prefName = "weather-cal-preferences-" + name
23 this.iCloudInUse = iCloudInUse
24 this.fm = iCloudInUse ? FileManager.iCloud() : FileManager.local()
25 this.prefPath = this.fm.joinPath(this.fm.libraryDirectory(), this.prefName)
26 this.widgetUrl = "https://raw.githubusercontent.com/mzeryck/Weather-Cal/main/weather-cal.js"
27 this.initialized = true
28 },
29
30 // Determine what to do when Weather Cal is run.
31 async runSetup(name, iCloudInUse, codeFilename, gitHubUrl) {
32
33 // Initialize the shared properties.
34 if (!this.initialized) this.initialize(name, iCloudInUse)
35
36 // If no setup file exists, this is the initial Weather Cal setup.
37 const setupPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-setup")
38 if (!this.fm.fileExists(setupPath)) { return await this.initialSetup() }
39
40 // If a settings file exists for this widget, we're editing settings.
41 const widgetpath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-" + name)
42 if (this.fm.fileExists(widgetpath)) { return await this.editSettings(codeFilename, gitHubUrl) }
43
44 // Otherwise, we're setting up this particular widget.
45 await this.generateAlert("Weather Cal is set up, but you need to choose a background for this widget.",["Continue"])
46 this.writePreference(this.prefName, this.defaultSettings())
47 return await this.setWidgetBackground()
48 },
49
50 // Run the initial setup.
51 async initialSetup() {
52
53 // Welcome the user and make sure they like the script name.
54 let message = "Welcome to Weather Cal. Make sure your script has the name you want before you begin."
55 let options = ['I like the name "' + this.name + '"', "Let me go change it"]
56 let shouldExit = await this.generateAlert(message,options)
57 if (shouldExit) return
58
59 // Welcome the user and check for permissions.
60 message = "Next, we need to check if you've given permissions to the Scriptable app. This might take a few seconds."
61 options = ["Check permissions"]
62 await this.generateAlert(message,options)
63
64 let errors = []
65 try { await Location.current() } catch { errors.push("location") }
66 try { await CalendarEvent.today() } catch { errors.push("calendar") }
67 try { await Reminder.all() } catch { errors.push("reminders") }
68
69 let issues
70 if (errors.length > 0) { issues = errors[0] }
71 if (errors.length == 2) { issues += " and " + errors[1] }
72 if (errors.length == 3) { issues += ", " + errors[1] + ", and " + errors[2] }
73
74 if (issues) {
75 message = "Scriptable does not have permission for " + issues + ". Some features may not work without enabling them in the Settings app."
76 options = ["Continue setup anyway", "Exit setup"]
77 } else {
78 message = "Your permissions are enabled."
79 options = ["Continue setup"]
80 }
81 shouldExit = await this.generateAlert(message,options)
82 if (shouldExit) return
83
84 // Set up the weather integration.
85 message = "To display the weather on your widget, you need an OpenWeather API key."
86 options = ["I already have a key", "I need to get a key", "I don't want to show weather info"]
87 const weather = await this.generateAlert(message,options)
88
89 // Show a web view to claim the API key.
90 if (weather == 1) {
91 message = "On the next screen, sign up for OpenWeather. Find the API key, copy it, and close the web view. You will then be prompted to paste in the key."
92 options = ["Continue"]
93 let weather = await this.generateAlert(message,options)
94
95 let webView = new WebView()
96 webView.loadURL("https://openweathermap.org/home/sign_up")
97 await webView.present()
98 }
99
100 // We need the API key if we're showing weather.
101 if (weather < 2) {
102 const response = await this.getWeatherKey(true)
103 if (!response) return
104 }
105
106 // Set up background image.
107 await this.setWidgetBackground()
108
109 // Write the default settings to disk.
110 this.writePreference(this.prefName, this.defaultSettings())
111
112 // Record setup completion.
113 this.writePreference("weather-cal-setup", "true")
114
115 message = "Your widget is ready! You'll now see a preview. Re-run this script to edit the default preferences, including localization. When you're ready, add a Scriptable widget to the home screen and select this script."
116 options = ["Show preview"]
117 await this.generateAlert(message,options)
118
119 // Return and show the preview.
120 return this.previewValue()
121
122 },
123
124 // Edit the widget settings.
125 async editSettings(codeFilename, gitHubUrl) {
126 let message = "Widget Setup"
127 let options = ["Show widget preview", "Change background", "Edit preferences", "Re-enter API key", "Update code", "Reset widget", "Exit settings menu"]
128 const response = await this.generateAlert(message,options)
129
130 // Return true to show the widget preview.
131 if (response == 0) {
132 return this.previewValue()
133 }
134
135 // Set the background and show a preview.
136 if (response == 1) {
137 await this.setWidgetBackground()
138 return true
139 }
140
141 // Display the preferences panel.
142 if (response == 2) {
143 await this.editPreferences()
144 return
145 }
146
147 // Set the API key.
148 if (response == 3) {
149 await this.getWeatherKey()
150 return
151 }
152
153 if (response == 4) {
154 // Prompt the user for updates.
155 message = "Would you like to update the Weather Cal code? Your widgets will not be affected."
156 options = ["Update", "Exit"]
157 const updateResponse = await this.generateAlert(message,options)
158
159 // Exit if the user didn't want to update.
160 if (updateResponse) return
161
162 // Try updating the code.
163 const success = await this.downloadCode(codeFilename, gitHubUrl)
164 message = success ? "The update is now complete." : "The update failed. Please try again later."
165 options = ["OK"]
166
167 await this.generateAlert(message,options)
168 return
169 }
170
171 // Reset the widget.
172 if (response == 5) {
173 const alert = new Alert()
174 alert.message = "Are you sure you want to completely reset this widget?"
175 alert.addDestructiveAction("Reset")
176 alert.addAction("Cancel")
177
178 const cancelReset = await alert.present()
179 if (cancelReset == 0) {
180 const bgPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-" + this.name)
181 if (this.fm.fileExists(bgPath)) { this.fm.remove(bgPath) }
182 if (this.fm.fileExists(this.prefPath)) { this.fm.remove(this.prefPath) }
183 const success = await this.downloadCode(this.name, this.widgetUrl)
184 message = success ? "This script has been reset. Close the script and reopen it for the change to take effect." : "The reset failed."
185 options = ["OK"]
186 await this.generateAlert(message,options)
187 }
188 return
189 }
190
191 // If response was Exit, just return.
192 return
193 },
194
195 // Get the weather key, optionally determining if it's the first run.
196 async getWeatherKey(firstRun = false) {
197
198 // Prompt for the key.
199 const returnVal = await this.promptForText("Paste your API key in the box below.",[""],["82c29fdbgd6aebbb595d402f8a65fabf"])
200 const apiKey = returnVal.textFieldValue(0)
201
202 let message, options
203 if (!apiKey || apiKey == "" || apiKey == null) {
204 message = "No API key was entered. Try copying the key again and re-running this script."
205 options = ["Exit"]
206 await this.generateAlert(message,options)
207 return false
208 }
209
210 // Save the key.
211 this.writePreference("weather-cal-api-key", apiKey)
212
213 // Test to see if the key works.
214 const req = new Request("https://api.openweathermap.org/data/2.5/onecall?lat=37.332280&lon=-122.010980&appid=" + apiKey)
215
216 let val = {}
217 try { val = await req.loadJSON() } catch { val.current = false }
218
219 // Warn the user if it didn't work.
220 if (!val.current) {
221 message = firstRun ? "New OpenWeather API keys may take a few hours to activate. Your widget will start displaying weather information once it's active." : "The key you entered, " + apiKey + ", didn't work. If it's a new key, it may take a few hours to activate."
222 options = [firstRun ? "Continue" : "OK"]
223 await this.generateAlert(message,options)
224
225 // Otherwise, confirm that it was saved.
226 } else if (val.current && !firstRun) {
227 message = "The new key worked and was saved."
228 options = ["OK"]
229 await this.generateAlert(message,options)
230 }
231
232 // If we made it this far, we did it.
233 return true
234 },
235
236 // Set the background of the widget.
237 async setWidgetBackground() {
238
239 // Prompt for the widget background.
240 let message = "What type of background would you like for your widget?"
241 let options = ["Solid color", "Automatic gradient", "Custom gradient", "Image from Photos"]
242 let backgroundType = await this.generateAlert(message,options)
243
244 let background = {}
245 let returnVal
246 if (backgroundType == 0) {
247 background.type = "color"
248 returnVal = await this.promptForText("Enter the hex value of the background color you want.",[""],["#007030"])
249 background.color = returnVal.textFieldValue(0)
250
251 } else if (backgroundType == 1) {
252 background.type = "auto"
253
254 } else if (backgroundType == 2) {
255 background.type = "gradient"
256 returnVal = await this.promptForText("Enter the hex value of the first gradient color.",[""],["#007030"])
257 background.initialColor = returnVal.textFieldValue(0)
258 returnVal = await this.promptForText("Enter the hex value of the second gradient color.",[""],["#007030"])
259 background.finalColor = returnVal.textFieldValue(0)
260
261 } else if (backgroundType == 3) {
262 background.type = "image"
263
264 // Create the Weather Cal directory if it doesn't already exist.
265 const dirPath = this.fm.joinPath(this.fm.documentsDirectory(), "Weather Cal")
266 if (!this.fm.fileExists(dirPath) || !this.fm.isDirectory(dirPath)) {
267 this.fm.createDirectory(dirPath)
268 }
269
270 // Determine if a dupe already exists.
271 const dupePath = this.fm.joinPath(dirPath, this.name + " 2.jpg")
272 const dupeAlreadyExists = this.fm.fileExists(dupePath)
273
274 // Get the image and write it to disk.
275 const img = await Photos.fromLibrary()
276 const path = this.fm.joinPath(dirPath, this.name + ".jpg")
277 this.fm.writeImage(path, img)
278
279 // If we just created a dupe, alert the user.
280 if (!dupeAlreadyExists && this.fm.fileExists(dupePath)) {
281 message = "Weather Cal detected a duplicate image. Please open the Files app, navigate to Scriptable > Weather Cal, and make sure the file named " + this.name + ".jpg is correct."
282 options = ["OK"]
283 const response = this.generateAlert(message,options)
284 }
285 }
286
287 this.writePreference("weather-cal-" + this.name, background)
288 return this.previewValue()
289 },
290
291 // Return the default widget settings.
292 defaultSettings() {
293 const settings = {
294
295 widget: {
296 name: "Overall settings",
297 locale: {
298 val: "",
299 name: "Locale code",
300 description: "Leave blank to match the device's locale.",
301 },
302 units: {
303 val: "imperial",
304 name: "Units",
305 description: "Use imperial for Fahrenheit or metric for Celsius.",
306 type: "enum",
307 options: ["imperial","metric"],
308 },
309 preview: {
310 val: "large",
311 name: "Widget preview size",
312 description: "Set the size of the widget preview displayed in the app.",
313 type: "enum",
314 options: ["small","medium","large"],
315 },
316 padding: {
317 val: "5",
318 name: "Item padding",
319 description: "The padding around each item. This also determines the approximate widget padding. Default is 5.",
320 },
321 widgetPadding: {
322 val: { top: "", left: "", bottom: "", right: "" },
323 name: "Custom widget padding",
324 type: "multival",
325 description: "The padding around the entire widget. By default, these values are blank and Weather Cal uses the item padding to determine these values. Transparent widgets often look best with these values at 0.",
326 },
327 tintIcons: {
328 val: false,
329 name: "Icons match text color",
330 description: "Decide if icons should match the color of the text around them.",
331 type: "bool",
332 },
333 },
334
335 localization: {
336 name: "Localization and text customization",
337 morningGreeting: {
338 val: "Good morning.",
339 name: "Morning greeting",
340 },
341 afternoonGreeting: {
342 val: "Good afternoon.",
343 name: "Afternoon greeting",
344 },
345 eveningGreeting: {
346 val: "Good evening.",
347 name: "Evening greeting",
348 },
349 nightGreeting: {
350 val: "Good night.",
351 name: "Night greeting",
352 },
353 nextHourLabel: {
354 val: "Next hour",
355 name: "Label for next hour of weather",
356 },
357 tomorrowLabel: {
358 val: "Tomorrow",
359 name: "Label for tomorrow",
360 },
361 noEventMessage: {
362 val: "Enjoy the rest of your day.",
363 name: "No event message",
364 description: "The message shown when there are no more events for the day, if that setting is active.",
365 },
366 durationMinute: {
367 val: "m",
368 name: "Duration label for minutes",
369 },
370 durationHour: {
371 val: "h",
372 name: "Duration label for hours",
373 },
374 covid: {
375 val: "{cases} cases, {deaths} deaths, {recovered} recoveries",
376 name: "COVID data text",
377 description: "Each {token} is replaced with the number from the data. The available tokens are: cases, todayCases, deaths, todayDeaths, recovered, active, critical, casesPerOneMillion, deathsPerOneMillion, totalTests, testsPerOneMillion"
378 },
379 week: {
380 val: "Week",
381 name: "Label for the week number",
382 },
383 },
384
385 font: {
386 name: "Text sizes, colors, and fonts",
387 defaultText: {
388 val: { size: "14", color: "ffffff", font: "regular" },
389 name: "Default font settings",
390 description: "These settings apply to all text on the widget that doesn't have a customized value.",
391 type: "multival",
392 },
393 smallDate: {
394 val: { size: "17", color: "", font: "semibold" },
395 name: "Small date",
396 type: "multival",
397 },
398 largeDate1: {
399 val: { size: "30", color: "", font: "light" },
400 name: "Large date, line 1",
401 type: "multival",
402 },
403 largeDate2: {
404 val: { size: "30", color: "", font: "light" },
405 name: "Large date, line 2",
406 type: "multival",
407 },
408 greeting: {
409 val: { size: "30", color: "", font: "semibold" },
410 name: "Greeting",
411 type: "multival",
412 },
413 eventLabel: {
414 val: { size: "14", color: "", font: "semibold" },
415 name: "Event heading (used for the TOMORROW label)",
416 type: "multival",
417 },
418 eventTitle: {
419 val: { size: "14", color: "", font: "semibold" },
420 name: "Event title",
421 type: "multival",
422 },
423 eventLocation: {
424 val: { size: "14", color: "", font: "" },
425 name: "Event location",
426 type: "multival",
427 },
428 eventTime: {
429 val: { size: "14", color: "ffffffcc", font: "" },
430 name: "Event time",
431 type: "multival",
432 },
433 noEvents: {
434 val: { size: "30", color: "", font: "semibold" },
435 name: "No events message",
436 type: "multival",
437 },
438 reminderTitle: {
439 val: { size: "14", color: "", font: "" },
440 name: "Reminder title",
441 type: "multival",
442 },
443 reminderTime: {
444 val: { size: "14", color: "ffffffcc", font: "" },
445 name: "Reminder time",
446 type: "multival",
447 },
448 largeTemp: {
449 val: { size: "34", color: "", font: "light" },
450 name: "Large temperature label",
451 type: "multival",
452 },
453 smallTemp: {
454 val: { size: "14", color: "", font: "" },
455 name: "Most text used in weather items",
456 type: "multival",
457 },
458 tinyTemp: {
459 val: { size: "12", color: "", font: "" },
460 name: "Small text used in weather items",
461 type: "multival",
462 },
463 customText: {
464 val: { size: "14", color: "", font: "" },
465 name: "User-defined text items",
466 type: "multival",
467 },
468 battery: {
469 val: { size: "14", color: "", font: "medium" },
470 name: "Battery percentage",
471 type: "multival",
472 },
473 sunrise: {
474 val: { size: "14", color: "", font: "medium" },
475 name: "Sunrise and sunset",
476 type: "multival",
477 },
478 covid: {
479 val: { size: "14", color: "", font: "medium" },
480 name: "COVID data",
481 type: "multival",
482 },
483 week: {
484 val: { size: "14", color: "", font: "light" },
485 name: "Week label",
486 type: "multival",
487 },
488 },
489
490 date: {
491 name: "Date",
492 dynamicDateSize: {
493 val: true,
494 name: "Dynamic date size",
495 description: "If set to true, the date will become smaller when events are displayed.",
496 type: "bool",
497 },
498 staticDateSize: {
499 val: "small",
500 name: "Static date size",
501 description: "Set the date size shown when dynamic date size is not enabled.",
502 type: "enum",
503 options: ["small","large"],
504 },
505 smallDateFormat: {
506 val: "EEEE, MMMM d",
507 name: "Small date format",
508 },
509 largeDateLineOne: {
510 val: "EEEE,",
511 name: "Large date format, line 1",
512 },
513 largeDateLineTwo: {
514 val: "MMMM d",
515 name: "Large date format, line 2",
516 },
517 },
518
519 events: {
520 name: "Events",
521 numberOfEvents: {
522 val: "3",
523 name: "Maximum number of events shown",
524 },
525 minutesAfter: {
526 val: "5",
527 name: "Minutes after event",
528 description: "Number of minutes after an event begins that it should still be shown.",
529 },
530 showAllDay: {
531 val: false,
532 name: "Show all-day events",
533 type: "bool",
534 },
535 showTomorrow: {
536 val: "20",
537 name: "Tomorrow's events shown at hour",
538 description: "The hour (in 24-hour time) to start showing tomorrow's events. Use 0 for always, 24 for never.",
539 },
540 showEventLength: {
541 val: "duration",
542 name: "Event length display style",
543 description: "Choose whether to show the duration, the end time, or no length information.",
544 type: "enum",
545 options: ["duration","time","none"],
546 },
547 showLocation: {
548 val: false,
549 name: "Show event location",
550 type: "bool",
551 },
552 selectCalendars: {
553 val: "",
554 name: "Calendars to show",
555 description: "Write the names of each calendar separated by commas, like this: Home,Work,Personal. Leave blank to show events from all calendars.",
556 },
557 showCalendarColor: {
558 val: "rectangle left",
559 name: "Display calendar color",
560 description: "Choose the shape and location of the calendar color.",
561 type: "enum",
562 options: ["rectangle left","rectangle right","circle left","circle right","none"],
563 },
564 noEventBehavior: {
565 val: "message",
566 name: "Show when no events remain",
567 description: "When no events remain, show a hard-coded message, a time-based greeting, or nothing.",
568 type: "enum",
569 options: ["message","greeting","none"],
570 },
571 url: {
572 val: "",
573 name: "URL to open when tapped",
574 description: "Optionally provide a URL to open when this item is tapped. Leave blank to open the built-in Calendar app.",
575 },
576 },
577
578 reminders: {
579 name: "Reminders",
580 numberOfReminders: {
581 val: "3",
582 name: "Maximum number of reminders shown",
583 },
584 useRelativeDueDate: {
585 val: false,
586 name: "Use relative dates",
587 description: "Set to true for a relative due date (in 3 hours) instead of absolute (3:00 PM).",
588 type: "bool",
589 },
590 showWithoutDueDate: {
591 val: false,
592 name: "Show reminders without a due date",
593 type: "bool",
594 },
595 showOverdue: {
596 val: false,
597 name: "Show overdue reminders",
598 type: "bool",
599 },
600 todayOnly: {
601 val: false,
602 name: "Hide reminders due after today",
603 type: "bool",
604 },
605 selectLists: {
606 val: "",
607 name: "Lists to show",
608 description: "Write the names of each list separated by commas, like this: Home,Work,Personal. Leave blank to show reminders from all lists.",
609 },
610 showListColor: {
611 val: "rectangle left",
612 name: "Display list color",
613 description: "Choose the shape and location of the list color.",
614 type: "enum",
615 options: ["rectangle left","rectangle right","circle left","circle right","none"],
616 },
617 url: {
618 val: "",
619 name: "URL to open when tapped",
620 description: "Optionally provide a URL to open when this item is tapped. Leave blank to open the built-in Reminders app.",
621 },
622 },
623
624 sunrise: {
625 name: "Sunrise and sunset",
626 showWithin: {
627 val: "",
628 name: "Limit times displayed",
629 description: "Set how many minutes before/after sunrise or sunset to show this element. Leave blank to always show.",
630 },
631 separateElements: {
632 val: false,
633 name: "Use separate sunrise and sunset elements",
634 description: "By default, the sunrise element changes between sunrise and sunset times automatically. Set to true for individual, hard-coded sunrise and sunset elements.",
635 type: "bool",
636 },
637 },
638
639 weather: {
640 name: "Weather",
641 showLocation: {
642 val: false,
643 name: "Show location name",
644 type: "bool",
645 },
646 horizontalCondition: {
647 val: false,
648 name: "Display the condition and temperature horizontally",
649 type: "bool",
650 },
651 showCondition: {
652 val: false,
653 name: "Show text value of the current condition",
654 type: "bool",
655 },
656 showHighLow: {
657 val: true,
658 name: "Show today's high and low temperatures",
659 type: "bool",
660 },
661 showRain: {
662 val: false,
663 name: "Show percent chance of rain",
664 type: "bool",
665 },
666 tomorrowShownAtHour: {
667 val: "20",
668 name: "When to switch to tomorrow's weather",
669 description: "Set the hour (in 24-hour time) to switch from the next hour to tomorrow's weather. Use 0 for always, 24 for never.",
670 },
671 spacing: {
672 val: "0",
673 name: "Spacing between forecast items",
674 },
675 showDays: {
676 val: "3",
677 name: "Number of days shown in the forecast item",
678 },
679 showDaysFormat: {
680 val: "E",
681 name: "Date format for the forecast item",
682 },
683 showToday: {
684 val: true,
685 name: "Show today's weather in the forecast item",
686 type: "bool",
687 },
688 urlCurrent: {
689 val: "",
690 name: "URL to open when current weather is tapped",
691 description: "Optionally provide a URL to open when this item is tapped. Leave blank for the default.",
692 },
693 urlFuture: {
694 val: "",
695 name: "URL to open when future weather is tapped",
696 description: "Optionally provide a URL to open when this item is tapped. Leave blank for the default.",
697 },
698 urlForecast: {
699 val: "",
700 name: "URL to open when the forecast item is tapped",
701 description: "Optionally provide a URL to open when this item is tapped. Leave blank for the default.",
702 },
703 },
704
705 covid: {
706 name: "COVID data",
707 country: {
708 val: "USA",
709 name: "Country for COVID information",
710 },
711 url: {
712 val: "https://covid19.who.int",
713 name: "URL to open when the COVID data is tapped",
714 },
715 },
716 }
717 return settings
718 },
719
720 // Load or reload a table full of preferences.
721 async loadTable(table,category,settingsObject) {
722 table.removeAllRows()
723 for (key in category) {
724 // Don't show the name as a setting.
725 if (key == "name") continue
726
727 // Make the row.
728 const row = new UITableRow()
729 row.dismissOnSelect = false
730 row.height = 55
731
732 // Fill it with the setting information.
733 const setting = category[key]
734
735 let valText
736 if (typeof setting.val == "object") {
737 for (subItem in setting.val) {
738 const setupText = subItem + ": " + setting.val[subItem]
739 if (!valText) {
740 valText = setupText
741 continue
742 }
743 valText += ", " + setupText
744 }
745 } else {
746 valText = setting.val + ""
747 }
748
749 const cell = row.addText(setting.name,valText)
750 cell.subtitleColor = Color.gray()
751
752 // If there's no type, it's just text.
753 if (!setting.type) {
754 row.onSelect = async () => {
755 const returnVal = await this.promptForText(setting.name,[setting.val],[],setting.description)
756 setting.val = returnVal.textFieldValue(0)
757 this.loadTable(table,category,settingsObject)
758 }
759
760 } else if (setting.type == "enum") {
761 row.onSelect = async () => {
762 const returnVal = await this.generateAlert(setting.name,setting.options,setting.description)
763 setting.val = setting.options[returnVal]
764 await this.loadTable(table,category,settingsObject)
765 }
766
767 } else if (setting.type == "bool") {
768 row.onSelect = async () => {
769 const returnVal = await this.generateAlert(setting.name,["true","false"],setting.description)
770 setting.val = !returnVal
771 await this.loadTable(table,category,settingsObject)
772 }
773
774 } else if (setting.type == "multival") {
775 row.onSelect = async () => {
776
777 // We need an ordered set.
778 let keys = []
779 let values = []
780 for (const item in setting.val) {
781 keys.push(item)
782 values.push(setting.val[item])
783 }
784
785 const returnVal = await this.promptForText(setting.name,values,keys,setting.description)
786
787 for (let i=0; i < keys.length; i++) {
788 const currentKey = keys[i]
789 setting.val[currentKey] = returnVal.textFieldValue(i)
790 }
791
792 await this.loadTable(table,category,settingsObject)
793 }
794 }
795 // Add it to the table.
796 table.addRow(row)
797 }
798
799 table.reload()
800 },
801
802 // Edit preferences of the widget.
803 async editPreferences() {
804
805 // Get the preferences object.
806 let settingsObject
807 if (!this.fm.fileExists(this.prefPath)) {
808 await this.generateAlert("No preferences file exists. If you're on an older version of Weather Cal, you need to reset your widget in order to use the preferences editor.",["OK"])
809 return
810
811 } else {
812 const settingsFromFile = JSON.parse(this.fm.readString(this.prefPath))
813 settingsObject = this.defaultSettings()
814
815 // Iterate through the settings object.
816 if (settingsFromFile.widget.units.val == undefined) {
817 for (category in settingsObject) {
818 for (item in settingsObject[category]) {
819
820 // If the setting exists, use it. Otherwise, the default is used.
821 if (settingsFromFile[category][item] != undefined) {
822 settingsObject[category][item].val = settingsFromFile[category][item]
823 }
824
825 }
826 }
827
828 // Fix for old preference files.
829 } else {
830 settingsObject = settingsFromFile
831 }
832 }
833
834 // Create the settings table.
835 const table = new UITable()
836 table.showSeparators = true
837
838 // Iterate through each item in the settings object.
839 for (key in settingsObject) {
840
841 // Make the row.
842 let row = new UITableRow()
843 row.dismissOnSelect = false
844
845 // Fill it with the category information.
846 const category = settingsObject[key]
847 row.addText(category.name)
848 row.onSelect = async () => {
849 const subTable = new UITable()
850 subTable.showSeparators = true
851 await this.loadTable(subTable,category,settingsObject)
852 await subTable.present()
853 }
854
855 // Add it to the table.
856 table.addRow(row)
857 }
858 await table.present()
859
860 // Upon dismissal, roll up preferences and write to disk.
861 for (category in settingsObject) {
862 for (item in settingsObject[category]) {
863 if (item == "name") continue
864 settingsObject[category][item] = settingsObject[category][item].val
865 }
866 }
867 this.writePreference(this.prefName, settingsObject)
868 },
869
870 // Return the widget preview value.
871 previewValue() {
872 if (this.fm.fileExists(this.prefPath)) {
873 let settingsObject = JSON.parse(this.fm.readString(this.prefPath))
874 return settingsObject.widget.preview || settingsObject.widget.preview.val
875 } else {
876 return "large"
877 }
878 },
879
880 // Download a Scriptable script.
881 async downloadCode(filename, url) {
882 const pathToCode = this.fm.joinPath(this.fm.documentsDirectory(), filename + ".js")
883 const req = new Request(url)
884
885 try {
886 const codeString = await req.loadString()
887 this.fm.writeString(pathToCode, codeString)
888 return true
889 } catch {
890 return false
891 }
892 },
893
894 // Generate an alert with the provided array of options.
895 async generateAlert(title,options,message = null) {
896
897 const alert = new Alert()
898 alert.title = title
899 if (message) alert.message = message
900
901 for (const option of options) {
902 alert.addAction(option)
903 }
904
905 const response = await alert.presentAlert()
906 return response
907 },
908
909 // Prompt for one or more text field values.
910 async promptForText(title,values,keys,message = null) {
911 const alert = new Alert()
912 alert.title = title
913 if (message) alert.message = message
914
915 for (let i=0; i < values.length; i++) {
916 alert.addTextField(keys ? (keys[i] || null) : null,values[i] + "")
917 }
918
919 alert.addAction("OK")
920 await alert.present()
921 return alert
922 },
923
924 // Write the value of a preference to disk.
925 writePreference(filename, value) {
926 const path = this.fm.joinPath(this.fm.libraryDirectory(), filename)
927
928 if (typeof value === "string") {
929 this.fm.writeString(path, value)
930 } else {
931 this.fm.writeString(path, JSON.stringify(value))
932 }
933 },
934
935 // Create and return the widget.
936 async createWidget(layout, name, iCloudInUse, custom) {
937
938 // Initialize if we haven't already.
939 if (!this.initialized) this.initialize(name, iCloudInUse)
940
941 // Determine if we're using the old or new setup.
942 if (typeof layout == "object") {
943 this.settings = layout
944
945 } else {
946 this.prefPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-preferences-" + name)
947 this.settings = JSON.parse(this.fm.readString(this.prefPath))
948
949 // Fix old preference files.
950 if (this.settings.widget.units.val != undefined) {
951 for (category in this.settings) {
952 for (item in this.settings[category]) {
953 this.settings[category][item] = this.settings[category][item].val
954 }
955 }
956 }
957 this.settings.layout = layout
958 }
959
960 // Initialize additional shared properties.
961 this.locale = this.settings.widget.locale
962 this.padding = parseInt(this.settings.widget.padding)
963 this.tintIcons = this.settings.widget.tintIcons
964 this.localization = this.settings.localization
965 this.format = this.settings.font
966 this.data = {}
967 this.now = new Date()
968 this.custom = custom
969
970 // Make sure we have a locale value.
971 if (!this.locale || this.locale == "" || this.locale == null) { this.locale = Device.locale() }
972
973 // Set up the widget.
974 this.widget = new ListWidget()
975 this.widget.spacing = 0
976
977 // Determine the default padding values.
978 const verticalPad = this.padding < 10 ? 10 - this.padding : 10
979 const horizontalPad = this.padding < 15 ? 15 - this.padding : 15
980
981 // If we have custom widget padding, use it.
982 const widgetPad = this.settings.widget.widgetPadding || {}
983 const topPad = widgetPad.top ? parseInt(widgetPad.top) : verticalPad
984 const leftPad = widgetPad.left ? parseInt(widgetPad.left) : horizontalPad
985 const bottomPad = widgetPad.bottom ? parseInt(widgetPad.bottom) : verticalPad
986 const rightPad = widgetPad.right ? parseInt(widgetPad.right) : horizontalPad
987
988 this.widget.setPadding(topPad, leftPad, bottomPad, rightPad)
989
990 /*
991 * BACKGROUND DISPLAY
992 * ==================
993 */
994
995 // Read the background information from disk.
996 const backgroundPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-" + name)
997 const backgroundRaw = this.fm.readString(backgroundPath)
998 const background = JSON.parse(backgroundRaw)
999
1000 if (custom && custom.background) {
1001 await custom.background(this.widget)
1002
1003 } else if (background.type == "color") {
1004 this.widget.backgroundColor = new Color(background.color)
1005
1006 } else if (background.type == "auto") {
1007 const gradient = new LinearGradient()
1008 const gradientSettings = await this.setupGradient()
1009
1010 gradient.colors = gradientSettings.color()
1011 gradient.locations = gradientSettings.position()
1012
1013 this.widget.backgroundGradient = gradient
1014
1015 } else if (background.type == "gradient") {
1016 const gradient = new LinearGradient()
1017 const initialColor = new Color(background.initialColor)
1018 const finalColor = new Color(background.finalColor)
1019
1020 gradient.colors = [initialColor, finalColor]
1021 gradient.locations = [0, 1]
1022
1023 this.widget.backgroundGradient = gradient
1024
1025 } else if (background.type == "image") {
1026
1027 // Determine if our image exists.
1028 const dirPath = this.fm.joinPath(this.fm.documentsDirectory(), "Weather Cal")
1029 const path = this.fm.joinPath(dirPath, name + ".jpg")
1030 const exists = this.fm.fileExists(path)
1031
1032 // If it exists, load from file.
1033 if (exists) {
1034 if (this.iCloudInUse) { await this.fm.downloadFileFromiCloud(path) }
1035 this.widget.backgroundImage = this.fm.readImage(path)
1036
1037 // If it's missing when running in the widget, use a gray background.
1038 } else if (!exists && config.runsInWidget) {
1039 this.widget.backgroundColor = Color.gray()
1040
1041 // But if we're running in app, prompt the user for the image.
1042 } else {
1043 const img = await Photos.fromLibrary()
1044 this.widget.backgroundImage = img
1045 this.fm.writeImage(path, img)
1046 }
1047 }
1048
1049 /*
1050 * CONSTRUCTION
1051 * ============
1052 */
1053
1054 // Set up the layout variables.
1055 this.currentRow = {}
1056 this.currentColumn = {}
1057
1058 // Set up the initial alignment.
1059 this.left()
1060
1061 // Set up the global ASCII variables.
1062 this.foundASCII = null
1063 this.usingASCII = null
1064 this.currentColumns = []
1065 this.rowNeedsSetup = false
1066
1067 // Process the layout.
1068 for (line of this.settings.layout.split(/\r?\n/)) {
1069 await this.processLine(line)
1070 }
1071
1072 // Finish the widget and return.
1073 return this.widget
1074 },
1075
1076 // Execute a function for the layout generator.
1077 async executeFunction(functionName,parameter = null) {
1078
1079 // If a custom function exists, use it.
1080 if (this.custom && this.custom[functionName]) {
1081 this.currentFunc = this.custom[functionName]
1082
1083 // Otherwise, use the built-in function.
1084 } else if (this[functionName]) {
1085 this.currentFunc = this[functionName]
1086
1087 // If we can't find it, we failed.
1088 } else { return false }
1089
1090 // If we were given a parameter, use it.
1091 if (parameter) {
1092 await this.currentFunc(this.currentColumn, parameter)
1093
1094 // Otherwise, just pass the column.
1095 } else {
1096 await this.currentFunc(this.currentColumn)
1097 }
1098 return true
1099
1100 },
1101
1102 // Process a single line of input.
1103 async processLine(lineInput,wc) {
1104
1105 // Trim the input.
1106 const line = lineInput.trim()
1107
1108 // If it's blank, return.
1109 if (line == '') { return }
1110
1111 // If we have a row, we're not using ASCII.
1112 if (!this.foundASCII && line.includes('row')) {
1113 this.foundASCII = true
1114 this.usingASCII = false
1115
1116 // If we have a row of dashes, we're using ASCII.
1117 } else if (!this.foundASCII && line[0] == '-' && line[line.length-1] == '-') {
1118 this.foundASCII = true
1119 this.usingASCII = true
1120 }
1121
1122 if (this.usingASCII) { await this.processASCIILine(line) }
1123 else { await this.processRegularLine(line) }
1124 },
1125
1126 // Process a single line of regular layout.
1127 async processRegularLine(lineInput) {
1128
1129 let line = lineInput
1130
1131 // If it's using the old style, remove the comma.
1132 if (line[line.length-1] == ',') {
1133 line = line.slice(0, -1)
1134 }
1135
1136 // If there are no parentheses, run the function.
1137 let item = line.split('(')
1138 if (!item[1]) {
1139 await this.executeFunction(item[0])
1140 return
1141 }
1142
1143 // Otherwise, pass the parameter.
1144 const param = item[1].slice(0, -1)
1145 await this.executeFunction(item[0],parseInt(param) || param)
1146 },
1147
1148 // Processes a single line of ASCII.
1149 async processASCIILine(lineInput) {
1150
1151 const line = lineInput.replace(/\.+/g,'')
1152
1153 // If it's a line, enumerate previous columns (if any) and set up the new row.
1154 if (line[0] == '-' && line[line.length-1] == '-') {
1155 if (this.currentColumns.length > 0) {
1156 for (col of this.currentColumns) {
1157
1158 // If it's null, go to the next one.
1159 if (!col) { continue }
1160
1161 // If there's a width, use the width function.
1162 if (col.width) {
1163 this.column(this.currentColumn,col.width)
1164
1165 // Otherwise, create the column normally.
1166 } else {
1167 this.column(this.currentColumn)
1168 }
1169 for (item of col.items) {
1170 await this.executeFunction(item)
1171 }
1172 }
1173 this.currentColumns = []
1174 }
1175 this.rowNeedsSetup = true
1176 return
1177 }
1178
1179 // If it's the first content row, finish the row setup.
1180 if (this.rowNeedsSetup) {
1181 this.row(this.currentColumn)
1182 this.rowNeedsSetup = false
1183 }
1184
1185 // If there's a number, this is a setup row.
1186 const setupRow = line.match(/\d+/)
1187
1188 // Otherwise, it has columns.
1189 const items = line.split('|')
1190
1191 // Iterate through each item.
1192 for (var i=1; i < items.length-1; i++) {
1193
1194 // If the current column doesn't exist, make it.
1195 if (!this.currentColumns[i]) { this.currentColumns[i] = { items: [] } }
1196
1197 // Now we have a column to add the items to.
1198 const column = this.currentColumns[i].items
1199
1200 // Get the current item and its trimmed version.
1201 const item = items[i]
1202 const trim = item.trim()
1203
1204 // If it's not a function, figure out spacing.
1205 const functionExists = this[trim] || (this.custom && this.custom[trim])
1206 if (!functionExists) {
1207
1208 // If it's a setup row, whether or not we find the number, we keep going.
1209 if (setupRow) {
1210 const value = parseInt(trim, 10)
1211 if (value) { this.currentColumns[i].width = value }
1212 continue
1213 }
1214
1215 // If it's blank and we haven't already added a space, add one.
1216 const prevItem = column[column.length-1]
1217 if (trim == '' && (!prevItem || (prevItem && !prevItem.startsWith("space")))) {
1218 column.push("space")
1219 }
1220
1221 // Either way, we're done.
1222 continue
1223
1224 }
1225
1226 // Determine the alignment.
1227 const index = item.indexOf(trim)
1228 const length = item.slice(index,item.length).length
1229
1230 let alignment
1231 if (index > 0 && length > trim.length) { alignment = "center" }
1232 else if (index > 0) { alignment = "right" }
1233 else { alignment = "left" }
1234
1235 // Add the items to the column.
1236 column.push(alignment)
1237 column.push(trim)
1238 }
1239 },
1240
1241 // Makes a new row on the widget.
1242 row(input, parameter) {
1243
1244 this.currentRow = this.widget.addStack()
1245 this.currentRow.layoutHorizontally()
1246 this.currentRow.setPadding(0, 0, 0, 0)
1247 this.currentColumn.spacing = 0
1248
1249 if (parameter) {
1250 this.currentRow.size = new Size(0,parameter)
1251 }
1252 },
1253
1254 // Makes a new column on the widget.
1255 column(input, parameter) {
1256
1257 this.currentColumn = this.currentRow.addStack()
1258 this.currentColumn.layoutVertically()
1259 this.currentColumn.setPadding(0, 0, 0, 0)
1260 this.currentColumn.spacing = 0
1261
1262 if (parameter) {
1263 this.currentColumn.size = new Size(parameter,0)
1264 }
1265 },
1266
1267 // This function adds a space, with an optional amount.
1268 space(input, parameter) {
1269
1270 if (parameter) { input.addSpacer(parameter) }
1271 else { input.addSpacer() }
1272
1273 },
1274
1275 /*
1276 * ALIGNMENT FUNCTIONS
1277 * These functions manage the alignment.
1278 * =============================================
1279 */
1280
1281 // Create an aligned stack to add content to.
1282 align(column) {
1283
1284 // Add the containing stack to the column.
1285 let alignmentStack = column.addStack()
1286 alignmentStack.layoutHorizontally()
1287
1288 // Get the correct stack from the alignment function.
1289 let returnStack = this.currentAlignment(alignmentStack)
1290 returnStack.layoutVertically()
1291 return returnStack
1292 },
1293
1294 // Change the current alignment to right.
1295 right() {
1296 function alignRight(alignmentStack) {
1297 alignmentStack.addSpacer()
1298 let returnStack = alignmentStack.addStack()
1299 return returnStack
1300 }
1301 this.currentAlignment = alignRight
1302 },
1303
1304 // Change the current alignment to left.
1305 left() {
1306 function alignLeft(alignmentStack) {
1307 let returnStack = alignmentStack.addStack()
1308 alignmentStack.addSpacer()
1309 return returnStack
1310 }
1311 this.currentAlignment = alignLeft
1312 },
1313
1314 // Change the current alignment to center.
1315 center() {
1316 function alignCenter(alignmentStack) {
1317 alignmentStack.addSpacer()
1318 let returnStack = alignmentStack.addStack()
1319 alignmentStack.addSpacer()
1320 return returnStack
1321 }
1322 this.currentAlignment = alignCenter
1323 },
1324
1325 /*
1326 * SETUP FUNCTIONS
1327 * These functions prepare data needed for items.
1328 * ==============================================
1329 */
1330
1331 // Set up the event data object.
1332 async setupEvents() {
1333
1334 this.data.events = {}
1335 const eventSettings = this.settings.events
1336
1337 let calSetting = eventSettings.selectCalendars
1338 let calendars = []
1339 if (Array.isArray(calSetting)) {
1340 calendars = calSetting
1341 } else if (typeof calSetting == "string") {
1342 calSetting = calSetting.trim()
1343 calendars = calSetting.length > 0 ? calSetting.split(",") : []
1344 }
1345
1346 const numberOfEvents = parseInt(eventSettings.numberOfEvents)
1347 const currentTime = this.now.getTime()
1348
1349 // Function to determine if an event should be shown.
1350 function shouldShowEvent(event) {
1351
1352 // If events are filtered and the calendar isn't in the selected calendars, return false.
1353 if (calendars.length && !calendars.includes(event.calendar.title)) { return false }
1354
1355 // Hack to remove canceled Office 365 events.
1356 if (event.title.startsWith("Canceled:")) { return false }
1357
1358 // If it's an all-day event, only show if the setting is active.
1359 if (event.isAllDay) { return eventSettings.showAllDay }
1360
1361 // Otherwise, return the event if it's in the future or recently started.
1362 const minutesAfter = parseInt(eventSettings.minutesAfter) * 60000 || 0
1363 return (event.startDate.getTime() + minutesAfter > currentTime)
1364 }
1365
1366 // Determine which events to show, and how many.
1367 const todayEvents = await CalendarEvent.today([])
1368 let shownEvents = 0
1369 let futureEvents = []
1370
1371 for (const event of todayEvents) {
1372 if (shownEvents == numberOfEvents) { break }
1373 if (shouldShowEvent(event)) {
1374 futureEvents.push(event)
1375 shownEvents++
1376 }
1377 }
1378
1379 // If there's room and we need to, show tomorrow's events.
1380 let multipleTomorrowEvents = false
1381 let showTomorrow = eventSettings.showTomorrow
1382
1383 // Determine if we're specifying an hour to show.
1384 if (!isNaN(parseInt(showTomorrow))) {
1385 showTomorrow = (this.now.getHours() >= parseInt(showTomorrow))
1386 }
1387
1388 if (showTomorrow && shownEvents < numberOfEvents) {
1389
1390 const tomorrowEvents = await CalendarEvent.tomorrow([])
1391 for (const event of tomorrowEvents) {
1392 if (shownEvents == numberOfEvents) { break }
1393 if (shouldShowEvent(event)) {
1394
1395 // Add the tomorrow label prior to the first tomorrow event.
1396 if (!multipleTomorrowEvents) {
1397
1398 // The tomorrow label is pretending to be an event.
1399 futureEvents.push({ title: this.localization.tomorrowLabel.toUpperCase(), isLabel: true })
1400 multipleTomorrowEvents = true
1401 }
1402
1403 // Show the tomorrow event and increment the counter.
1404 futureEvents.push(event)
1405 shownEvents++
1406 }
1407 }
1408 }
1409
1410 // Store the future events, and whether or not any events are displayed.
1411 this.data.events.futureEvents = futureEvents
1412 this.data.events.eventsAreVisible = (futureEvents.length > 0) && (eventSettings.numberOfEvents > 0)
1413 },
1414
1415 // Set up the reminders data object.
1416 async setupReminders() {
1417
1418 this.data.reminders = {}
1419 const reminderSettings = this.settings.reminders
1420
1421 let listSetting = reminderSettings.selectLists
1422 let lists = []
1423 if (Array.isArray(listSetting)) {
1424 lists = listSetting
1425 } else if (typeof listSetting == "string") {
1426 listSetting = listSetting.trim()
1427 lists = listSetting.length > 0 ? listSetting.split(",") : []
1428 }
1429
1430 const numberOfReminders = parseInt(reminderSettings.numberOfReminders)
1431 const showWithoutDueDate = reminderSettings.showWithoutDueDate
1432 const showOverdue = reminderSettings.showOverdue
1433
1434 const sameDay = this.sameDay
1435 const currentDate = this.now
1436
1437 // Function to determine if an event should be shown.
1438 function shouldShowReminder(reminder) {
1439
1440 // If reminders are filtered and the list isn't in the selected lists, return false.
1441 if (lists.length && !lists.includes(reminder.calendar.title)) { return false }
1442
1443 // If there's no due date, use the setting.
1444 if (!reminder.dueDate) { return showWithoutDueDate }
1445
1446 // If it's overdue, use the setting.
1447 if (reminder.isOverdue) { return showOverdue }
1448
1449 // If we only want today and overdue, use the setting.
1450 if (reminderSettings.todayOnly) {
1451 return sameDay(reminder.dueDate, currentDate)
1452 }
1453
1454 // Otherwise, return true.
1455 return true
1456 }
1457
1458 // Determine which reminders to show.
1459 let reminders = await Reminder.allIncomplete()
1460
1461 // Sort in order of due date.
1462 reminders.sort(function(a, b) {
1463
1464 // Due dates are always picked first.
1465 if (!a.dueDate && b.dueDate) return 1
1466 if (a.dueDate && !b.dueDate) return -1
1467 if (!a.dueDate && !b.dueDate) return 0
1468
1469 // Otherwise, earlier due dates go first.
1470 const aTime = a.dueDate.getTime()
1471 const bTime = b.dueDate.getTime()
1472
1473 if (aTime > bTime) return 1
1474 if (aTime < bTime) return -1
1475 return 0
1476 })
1477
1478 // Set the number of reminders shown.
1479 reminders = reminders.filter(shouldShowReminder).slice(0,numberOfReminders)
1480
1481 // Store the data.
1482 this.data.reminders.all = reminders
1483 },
1484
1485 // Set up the gradient for the widget background.
1486 async setupGradient() {
1487
1488 // Requirements: sunrise
1489 if (!this.data.sun) { await this.setupSunrise() }
1490
1491 let gradient = {
1492 dawn: {
1493 color() { return [new Color("142C52"), new Color("1B416F"), new Color("62668B")] },
1494 position() { return [0, 0.5, 1] },
1495 },
1496
1497 sunrise: {
1498 color() { return [new Color("274875"), new Color("766f8d"), new Color("f0b35e")] },
1499 position() { return [0, 0.8, 1.5] },
1500 },
1501
1502 midday: {
1503 color() { return [new Color("3a8cc1"), new Color("90c0df")] },
1504 position() { return [0, 1] },
1505 },
1506
1507 noon: {
1508 color() { return [new Color("b2d0e1"), new Color("80B5DB"), new Color("3a8cc1")] },
1509 position() { return [-0.2, 0.2, 1.5] },
1510 },
1511
1512 sunset: {
1513 color() { return [new Color("32327A"), new Color("662E55"), new Color("7C2F43")] },
1514 position() { return [0.1, 0.9, 1.2] },
1515 },
1516
1517 twilight: {
1518 color() { return [new Color("021033"), new Color("16296b"), new Color("414791")] },
1519 position() { return [0, 0.5, 1] },
1520 },
1521
1522 night: {
1523 color() { return [new Color("16296b"), new Color("021033"), new Color("021033"), new Color("113245")] },
1524 position() { return [-0.5, 0.2, 0.5, 1] },
1525 },
1526 }
1527
1528 const sunrise = this.data.sun.sunrise
1529 const sunset = this.data.sun.sunset
1530
1531 // Use sunrise or sunset if we're within 30min of it.
1532 if (this.closeTo(sunrise)<=15) { return gradient.sunrise }
1533 if (this.closeTo(sunset)<=15) { return gradient.sunset }
1534
1535 // In the 30min before/after, use dawn/twilight.
1536 if (this.closeTo(sunrise)<=45 && this.now.getTime() < sunrise) { return gradient.dawn }
1537 if (this.closeTo(sunset)<=45 && this.now.getTime() > sunset) { return gradient.twilight }
1538
1539 // Otherwise, if it's night, return night.
1540 if (this.isNight(this.now)) { return gradient.night }
1541
1542 // If it's around noon, the sun is high in the sky.
1543 if (this.now.getHours() == 12) { return gradient.noon }
1544
1545 // Otherwise, return the "typical" theme.
1546 return gradient.midday
1547 },
1548
1549 // Set up the location data object.
1550 async setupLocation() {
1551
1552 // Get the cached location info if it exists.
1553 const locationPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-location")
1554 const locationExists = this.fm.fileExists(locationPath)
1555 let cachedLocation
1556 if (locationExists) {
1557 cachedLocation = JSON.parse(this.fm.readString(locationPath))
1558 }
1559
1560 // If it's been more than an hour, ask iOS for location.
1561 let location, geocode
1562 const timeToCache = 60 * 60 * 1000
1563 const locationDate = locationExists ? this.fm.modificationDate(locationPath).getTime() : -(timeToCache+1)
1564 const locationDataOld = (this.now.getTime() - locationDate) > timeToCache
1565 if (locationDataOld) {
1566 try {
1567 location = await Location.current()
1568 geocode = location ? await Location.reverseGeocode(location.latitude, location.longitude, this.locale) : false
1569 } catch {}
1570 }
1571
1572 // Store the possible location values in the data object.
1573 this.data.location = {}
1574
1575 if (location) {
1576 this.data.location.latitude = location.latitude
1577 this.data.location.longitude = location.longitude
1578 } else {
1579 this.data.location.latitude = cachedLocation.latitude
1580 this.data.location.longitude = cachedLocation.longitude
1581 }
1582
1583 if (geocode) {
1584 this.data.location.locality = (geocode[0].locality || geocode[0].postalAddress.city) || geocode[0].administrativeArea
1585 } else {
1586 this.data.location.locality = cachedLocation.locality
1587 }
1588
1589 // If we have old location data, save it to disk.
1590 if (locationDataOld) {
1591 this.fm.writeString(locationPath, JSON.stringify(this.data.location))
1592 }
1593 },
1594
1595 // Set up the sun data object.
1596 async setupSunrise() {
1597
1598 // Requirements: location
1599 if (!this.data.location) { await this.setupLocation() }
1600
1601 let data = this.data
1602 async function getSunData(date) {
1603 const req = "https://api.sunrise-sunset.org/json?lat=" + data.location.latitude + "&lng=" + data.location.longitude + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth()+1) + "-" + date.getDate()
1604 const sunData = await new Request(req).loadJSON()
1605 return sunData
1606 }
1607
1608 // Set up the sunrise/sunset cache.
1609 const sunCachePath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-sunrise")
1610 const sunCacheExists = this.fm.fileExists(sunCachePath)
1611 const sunCacheDate = sunCacheExists ? this.fm.modificationDate(sunCachePath) : 0
1612 let sunDataRaw
1613
1614 // If cache exists and was created today, use cached data.
1615 if (sunCacheExists && this.sameDay(this.now, sunCacheDate)) {
1616 const sunCache = this.fm.readString(sunCachePath)
1617 sunDataRaw = JSON.parse(sunCache)
1618 }
1619
1620 // Otherwise, get the data from the server.
1621 else {
1622
1623 sunDataRaw = await getSunData(this.now)
1624
1625 // Calculate tomorrow's date and get tomorrow's data.
1626 let tomorrowDate = new Date()
1627 tomorrowDate.setDate(this.now.getDate() + 1)
1628 const tomorrowData = await getSunData(tomorrowDate)
1629 sunDataRaw.results.tomorrow = tomorrowData.results.sunrise
1630
1631 // Cache the file.
1632 this.fm.writeString(sunCachePath, JSON.stringify(sunDataRaw))
1633 }
1634
1635 // Store the timing values.
1636 this.data.sun = {}
1637 this.data.sun.sunrise = new Date(sunDataRaw.results.sunrise).getTime()
1638 this.data.sun.sunset = new Date(sunDataRaw.results.sunset).getTime()
1639 this.data.sun.tomorrow = new Date(sunDataRaw.results.tomorrow).getTime()
1640 },
1641
1642 // Set up the weather data object.
1643 async setupWeather() {
1644
1645 // Get the weather settings.
1646 const weatherSettings = this.settings.weather
1647
1648 // Requirements: location
1649 if (!this.data.location) { await this.setupLocation() }
1650
1651 // Set up the cache.
1652 const cachePath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-cache")
1653 const cacheExists = this.fm.fileExists(cachePath)
1654 const cacheDate = cacheExists ? this.fm.modificationDate(cachePath) : 0
1655 let weatherDataRaw
1656
1657 // If cache exists and it's been less than 60 seconds since last request, use cached data.
1658 if (cacheExists && (this.now.getTime() - cacheDate.getTime()) < 60000) {
1659 const cache = this.fm.readString(cachePath)
1660 weatherDataRaw = JSON.parse(cache)
1661
1662 // Otherwise, use the API to get new weather data.
1663 } else {
1664
1665 // OpenWeather only supports a subset of language codes.
1666 const openWeatherLang = ["af","al","ar","az","bg","ca","cz","da","de","el","en","eu","fa","fi","fr","gl","he","hi","hr","hu","id","it","ja","kr","la","lt","mk","no","nl","pl","pt","pt_br","ro","ru","sv","se","sk","sl","sp","es","sr","th","tr","ua","uk","vi","zh_cn","zh_tw","zu"]
1667 var lang
1668
1669 // Find all possible language matches.
1670 const languages = [this.locale, this.locale.split("_")[0], Device.locale(), Device.locale().split("_")[0]]
1671
1672 for (item of languages) {
1673 // If it matches, use the value and stop the loop.
1674 if (openWeatherLang.includes(item)) {
1675 lang = "&lang=" + item
1676 break
1677 }
1678 }
1679
1680 const apiKeyPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-api-key")
1681 const apiKey = this.fm.readString(apiKeyPath)
1682
1683 try {
1684 const weatherReq = "https://api.openweathermap.org/data/2.5/onecall?lat=" + this.data.location.latitude + "&lon=" + this.data.location.longitude + "&exclude=minutely,alerts&units=" + this.settings.widget.units + lang + "&appid=" + apiKey
1685 weatherDataRaw = await new Request(weatherReq).loadJSON()
1686 this.fm.writeString(cachePath, JSON.stringify(weatherDataRaw))
1687 } catch {}
1688 }
1689
1690 // If it's an error, treat it as a null value.
1691 if (weatherDataRaw.cod) { weatherDataRaw = null }
1692
1693 // English continues using the "main" weather description.
1694 const english = (this.locale.split("_")[0] == "en")
1695
1696 // Store the weather values.
1697 this.data.weather = {}
1698 this.data.weather.currentTemp = weatherDataRaw ? weatherDataRaw.current.temp : null
1699 this.data.weather.currentCondition = weatherDataRaw ? weatherDataRaw.current.weather[0].id : 100
1700 this.data.weather.currentDescription = weatherDataRaw ? (english ? weatherDataRaw.current.weather[0].main : weatherDataRaw.current.weather[0].description) : "--"
1701 this.data.weather.todayHigh = weatherDataRaw ? weatherDataRaw.daily[0].temp.max : null
1702 this.data.weather.todayLow = weatherDataRaw ? weatherDataRaw.daily[0].temp.min : null
1703 this.data.weather.forecast = [];
1704
1705 for (let i=0; i <= 7; i++) {
1706 this.data.weather.forecast[i] = weatherDataRaw ? ({High: weatherDataRaw.daily[i].temp.max, Low: weatherDataRaw.daily[i].temp.min, Condition: weatherDataRaw.daily[i].weather[0].id}) : { High: null, Low: null, Condition: 100 }
1707 }
1708 this.data.weather.tomorrowRain = weatherDataRaw ? weatherDataRaw.daily[1].pop : null
1709
1710 this.data.weather.nextHourTemp = weatherDataRaw ? weatherDataRaw.hourly[1].temp : null
1711 this.data.weather.nextHourCondition = weatherDataRaw ? weatherDataRaw.hourly[1].weather[0].id : 100
1712 this.data.weather.nextHourRain = weatherDataRaw ? weatherDataRaw.hourly[1].pop : null
1713 },
1714
1715 // Set up the COVID data object.
1716 async setupCovid() {
1717
1718 // Set up the COVID cache.
1719 const cacheCovidPath = this.fm.joinPath(this.fm.libraryDirectory(), "weather-cal-covid")
1720 const cacheCovidExists = this.fm.fileExists(cacheCovidPath)
1721 const cacheCovidDate = cacheCovidExists ? this.fm.modificationDate(cacheCovidPath) : 0
1722 let covidDataRaw
1723
1724 // If cache exists and it's been less than 900 seconds (15min) since last request, use cached data.
1725 if (cacheCovidExists && (this.now.getTime() - cacheCovidDate.getTime()) < 900000) {
1726 const cacheCovid = this.fm.readString(cacheCovidPath)
1727 covidDataRaw = JSON.parse(cacheCovid)
1728
1729 // Otherwise, use the API to get new data.
1730 } else {
1731 const covidReq = "https://coronavirus-19-api.herokuapp.com/countries/" + this.settings.covid.country
1732 covidDataRaw = await new Request(covidReq).loadJSON()
1733 this.fm.writeString(cacheCovidPath, JSON.stringify(covidDataRaw))
1734 }
1735
1736 this.data.covid = covidDataRaw
1737 },
1738
1739 /*
1740 * WIDGET ITEMS
1741 * These functions display items on the widget.
1742 * ============================================
1743 */
1744
1745 // Display the date on the widget.
1746 async date(column) {
1747
1748 // Get the settings.
1749 const dateSettings = this.settings.date
1750
1751 // Requirements: events (if dynamicDateSize is enabled)
1752 if (!this.data.events && dateSettings.dynamicDateSize) { await this.setupEvents() }
1753
1754 // Set up the date formatter and set its locale.
1755 let df = new DateFormatter()
1756 df.locale = this.locale
1757
1758 // Show small if it's hard coded, or if it's dynamic and events are visible.
1759 if (dateSettings.dynamicDateSize ? this.data.events.eventsAreVisible : dateSettings.staticDateSize == "small") {
1760 let dateStack = this.align(column)
1761 dateStack.setPadding(this.padding, this.padding, this.padding, this.padding)
1762
1763 df.dateFormat = dateSettings.smallDateFormat
1764 let dateText = this.provideText(df.string(this.now), dateStack, this.format.smallDate)
1765
1766 // Otherwise, show the large date.
1767 } else {
1768 let dateOneStack = this.align(column)
1769 df.dateFormat = dateSettings.largeDateLineOne
1770 let dateOne = this.provideText(df.string(this.now), dateOneStack, this.format.largeDate1)
1771 dateOneStack.setPadding(this.padding/2, this.padding, 0, this.padding)
1772
1773 let dateTwoStack = this.align(column)
1774 df.dateFormat = dateSettings.largeDateLineTwo
1775 let dateTwo = this.provideText(df.string(this.now), dateTwoStack, this.format.largeDate2)
1776 dateTwoStack.setPadding(0, this.padding, this.padding, this.padding)
1777 }
1778 },
1779
1780 // Display a time-based greeting on the widget.
1781 async greeting(column) {
1782
1783 // This function makes a greeting based on the time of day.
1784 const localization = this.localization
1785 const hour = this.now.getHours()
1786
1787 function makeGreeting() {
1788 if (hour < 5) { return localization.nightGreeting }
1789 if (hour < 12) { return localization.morningGreeting }
1790 if (hour-12 < 5) { return localization.afternoonGreeting }
1791 if (hour-12 < 10) { return localization.eveningGreeting }
1792 return localization.nightGreeting
1793 }
1794
1795 // Set up the greeting.
1796 let greetingStack = this.align(column)
1797 let greeting = this.provideText(makeGreeting(), greetingStack, this.format.greeting)
1798 greetingStack.setPadding(this.padding, this.padding, this.padding, this.padding)
1799 },
1800
1801 // Display events on the widget.
1802 async events(column) {
1803
1804 // Requirements: events
1805 if (!this.data.events) { await this.setupEvents() }
1806
1807 // Get the event data and settings.
1808 const eventData = this.data.events
1809 const eventSettings = this.settings.events
1810
1811 // If no events are visible, figure out what to do.
1812 if (!eventData.eventsAreVisible) {
1813 const display = eventSettings.noEventBehavior
1814
1815 // If it's a greeting, let the greeting function handle it.
1816 if (display == "greeting") { return await greeting(column) }
1817
1818 // If it's a message, get the localized text.
1819 if (display == "message" && this.localization.noEventMessage.length) {
1820 const messageStack = this.align(column)
1821 messageStack.setPadding(this.padding, this.padding, this.padding, this.padding)
1822 this.provideText(this.localization.noEventMessage, messageStack, this.format.noEvents)
1823 }
1824
1825 // Whether or not we displayed something, return here.
1826 return
1827 }
1828
1829 // Set up the event stack.
1830 let eventStack = column.addStack()
1831 eventStack.layoutVertically()
1832 const todaySeconds = Math.floor(this.now.getTime() / 1000) - 978307200
1833
1834 const defaultUrl = 'calshow:' + todaySeconds
1835 const settingUrlExists = (eventSettings.url || "").length > 0
1836 eventStack.url = settingUrlExists ? eventSettings.url : defaultUrl
1837
1838 // If there are no events and we have a message, show it and return.
1839 if (!eventData.eventsAreVisible && this.localization.noEventMessage.length) {
1840 let message = this.provideText(this.localization.noEventMessage, eventStack, this.format.noEvents)
1841 eventStack.setPadding(this.padding, this.padding, this.padding, this.padding)
1842 return
1843 }
1844
1845 // If we're not showing the message, don't pad the event stack.
1846 eventStack.setPadding(0, 0, 0, 0)
1847
1848 // Add each event to the stack.
1849 var currentStack = eventStack
1850 const futureEvents = eventData.futureEvents
1851 const showCalendarColor = eventSettings.showCalendarColor
1852 const colorShape = showCalendarColor.includes("circle") ? "circle" : "rectangle"
1853
1854 for (let i = 0; i < futureEvents.length; i++) {
1855
1856 const event = futureEvents[i]
1857 const bottomPadding = (this.padding-10 < 0) ? 0 : this.padding-10
1858
1859 // If it's the tomorrow label, change to the tomorrow stack.
1860 if (event.isLabel) {
1861 let tomorrowStack = column.addStack()
1862 tomorrowStack.layoutVertically()
1863 const tomorrowSeconds = Math.floor(this.now.getTime() / 1000) - 978220800
1864 tomorrowStack.url = settingUrlExists ? eventSettings.url : 'calshow:' + tomorrowSeconds
1865 currentStack = tomorrowStack
1866
1867 // Mimic the formatting of an event title, mostly.
1868 const eventLabelStack = this.align(currentStack)
1869 const eventLabel = this.provideText(event.title, eventLabelStack, this.format.eventLabel)
1870 eventLabelStack.setPadding(this.padding, this.padding, this.padding, this.padding)
1871 continue
1872 }
1873
1874 const titleStack = this.align(currentStack)
1875 titleStack.layoutHorizontally()
1876
1877 // If we're showing a color, and it's not shown on the right, add it to the left.
1878 if (showCalendarColor.length && !showCalendarColor.includes("right")) {
1879 let colorItemText = this.provideTextSymbol(colorShape) + " "
1880 let colorItem = this.provideText(colorItemText, titleStack, this.format.eventTitle)
1881 colorItem.textColor = event.calendar.color
1882 }
1883
1884 // Determine which elements will be shown.
1885 const showLocation = eventSettings.showLocation && event.location
1886 const showTime = !event.isAllDay
1887
1888 // Set up the title.
1889 const title = this.provideText(event.title.trim(), titleStack, this.format.eventTitle)
1890 const titlePadding = (showLocation || showTime) ? this.padding/5 : this.padding
1891 titleStack.setPadding(this.padding, this.padding, titlePadding, this.padding)
1892
1893 // If we're showing a color on the right, show it.
1894 if (showCalendarColor.length && showCalendarColor.includes("right")) {
1895 let colorItemText = " " + this.provideTextSymbol(colorShape)
1896 let colorItem = this.provideText(colorItemText, titleStack, this.format.eventTitle)
1897 colorItem.textColor = event.calendar.color
1898 }
1899
1900 // If there are too many events, limit the line height.
1901 if (futureEvents.length >= 3) { title.lineLimit = 1 }
1902
1903 // Show the location if enabled.
1904 if (showLocation) {
1905 const locationStack = this.align(currentStack)
1906 const location = this.provideText(event.location, locationStack, this.format.eventLocation)
1907 location.lineLimit = 1
1908 locationStack.setPadding(0, this.padding, showTime ? this.padding/5 : this.padding, this.padding)
1909 }
1910
1911 // If it's an all-day event, we don't need a time.
1912 if (event.isAllDay) { continue }
1913
1914 // Format the time information.
1915 let timeText = this.formatTime(event.startDate)
1916
1917 // If we show the length as time, add an en dash and the time.
1918 if (eventSettings.showEventLength == "time") {
1919 timeText += "–" + this.formatTime(event.endDate)
1920
1921 // If we should it as a duration, add the minutes.
1922 } else if (eventSettings.showEventLength == "duration") {
1923 const duration = (event.endDate.getTime() - event.startDate.getTime()) / (1000*60)
1924 const hours = Math.floor(duration/60)
1925 const minutes = Math.floor(duration % 60)
1926 const hourText = hours>0 ? hours + this.localization.durationHour : ""
1927 const minuteText = minutes>0 ? minutes + this.localization.durationMinute : ""
1928 const showSpace = hourText.length && minuteText.length
1929 timeText += " \u2022 " + hourText + (showSpace ? " " : "") + minuteText
1930 }
1931
1932 const timeStack = this.align(currentStack)
1933 const time = this.provideText(timeText, timeStack, this.format.eventTime)
1934 timeStack.setPadding(0, this.padding, this.padding, this.padding)
1935 }
1936 },
1937
1938 // Display reminders on the widget.
1939 async reminders(column) {
1940
1941 // Requirements: reminders
1942 if (!this.data.reminders) { await this.setupReminders() }
1943
1944 // Get the reminders data and settings.
1945 const reminderData = this.data.reminders
1946 const reminderSettings = this.settings.reminders
1947
1948 // Set up the reminders stack.
1949 let reminderStack = column.addStack()
1950 reminderStack.layoutVertically()
1951 reminderStack.setPadding(0, 0, 0, 0)
1952
1953 const defaultUrl = "x-apple-reminderkit://REMCDReminder/"
1954 const settingUrl = reminderSettings.url || ""
1955 reminderStack.url = (settingUrl.length > 0) ? settingUrl : defaultUrl
1956
1957 // Add each reminder to the stack.
1958 const reminders = reminderData.all
1959 const showListColor = reminderSettings.showListColor
1960 const colorShape = showListColor.includes("circle") ? "circle" : "rectangle"
1961
1962 for (let i = 0; i < reminders.length; i++) {
1963
1964 const reminder = reminders[i]
1965 const bottomPadding = (this.padding-10 < 0) ? 0 : this.padding-10
1966
1967 const titleStack = this.align(reminderStack)
1968 titleStack.layoutHorizontally()
1969 const showCalendarColor = reminderSettings.showListColor
1970 const colorShape = showListColor.includes("circle") ? "circle" : "rectangle"
1971
1972 // If we're showing a color, and it's not shown on the right, add it to the left.
1973 if (showListColor.length && !showListColor.includes("right")) {
1974 let colorItemText = this.provideTextSymbol(colorShape) + " "
1975 let colorItem = this.provideText(colorItemText, titleStack, this.format.reminderTitle)
1976 colorItem.textColor = reminder.calendar.color
1977 }
1978
1979 const title = this.provideText(reminder.title.trim(), titleStack, this.format.reminderTitle)
1980 titleStack.setPadding(this.padding, this.padding, this.padding/5, this.padding)
1981
1982 // If we're showing a color on the right, show it.
1983 if (showListColor.length && showListColor.includes("right")) {
1984 let colorItemText = " " + this.provideTextSymbol(colorShape)
1985 let colorItem = this.provideText(colorItemText, titleStack, this.format.reminderTitle)
1986 colorItem.textColor = reminder.calendar.color
1987 }
1988
1989 // If it doesn't have a due date, keep going.
1990 if (!reminder.dueDate) { continue }
1991
1992 // If it's overdue, display in red without a time.
1993 if (reminder.isOverdue) {
1994 title.textColor = Color.red()
1995 continue
1996 }
1997
1998 // Format with the relative style if set.
1999 let timeText
2000 if (reminderSettings.useRelativeDueDate) {
2001 let rdf = new RelativeDateTimeFormatter()
2002 rdf.locale = this.locale
2003 rdf.useNamedDateTimeStyle()
2004 timeText = rdf.string(reminder.dueDate, this.now)
2005
2006 // Otherwise, use a normal date, time, or datetime format.
2007 } else {
2008 let df = new DateFormatter()
2009 df.locale = this.locale
2010
2011 // If it's due today and it has a time, don't show the date.
2012 if (this.sameDay(reminder.dueDate, this.now) && reminder.dueDateIncludesTime) {
2013 df.useNoDateStyle()
2014 } else {
2015 df.useShortDateStyle()
2016 }
2017
2018 // Only show the time if it's available.
2019 if (reminder.dueDateIncludesTime) {
2020 df.useShortTimeStyle()
2021 } else {
2022 df.useNoTimeStyle()
2023 }
2024
2025 timeText = df.string(reminder.dueDate)
2026 }
2027
2028 const timeStack = this.align(reminderStack)
2029 const time = this.provideText(timeText, timeStack, this.format.eventTime)
2030 timeStack.setPadding(0, this.padding, this.padding, this.padding)
2031 }
2032 },
2033
2034 // Display the current weather.
2035 async current(column) {
2036
2037 // Requirements: location, weather, and sunrise
2038 if (!this.data.location) { await this.setupLocation() }
2039 if (!this.data.weather) { await this.setupWeather() }
2040 if (!this.data.sun) { await this.setupSunrise() }
2041
2042 // Get the relevant data and weather settings.
2043 const [locationData, weatherData, sunData] = [this.data.location, this.data.weather, this.data.sun]
2044 const weatherSettings = this.settings.weather
2045
2046 // Set up the current weather stack.
2047 let currentWeatherStack = column.addStack()
2048 currentWeatherStack.layoutVertically()
2049 currentWeatherStack.setPadding(0, 0, 0, 0)
2050
2051 const defaultUrl = "https://weather.com/" + this.locale + "/weather/today/l/" + locationData.latitude + "," + locationData.longitude
2052 const settingUrl = weatherSettings.urlCurrent || ""
2053 currentWeatherStack.url = (settingUrl.length > 0) ? settingUrl : defaultUrl
2054
2055 // If we're showing the location, add it.
2056 if (weatherSettings.showLocation) {
2057 let locationTextStack = this.align(currentWeatherStack)
2058 let locationText = this.provideText(locationData.locality, locationTextStack, this.format.smallTemp)
2059 locationTextStack.setPadding(this.padding, this.padding, this.padding, this.padding)
2060 }
2061
2062 // Show the current condition symbol.
2063 let mainConditionStack = this.align(currentWeatherStack)
2064 let mainCondition = mainConditionStack.addImage(this.provideConditionSymbol(weatherData.currentCondition,this.isNight(this.now)))
2065 mainCondition.imageSize = new Size(22,22)
2066 this.tintIcon(mainCondition, this.format.largeTemp)
2067 mainConditionStack.setPadding(weatherSettings.showLocation ? 0 : this.padding, this.padding, 0, this.padding)
2068
2069 // Add the temp horizontally if enabled.
2070 if (weatherSettings.horizontalCondition) {
2071 mainConditionStack.addSpacer(5)
2072 mainConditionStack.layoutHorizontally()
2073 mainConditionStack.centerAlignContent()
2074 const tempText = this.displayNumber(weatherData.currentTemp,"--") + "°"
2075 const temp = this.provideText(tempText, mainConditionStack, this.format.largeTemp)
2076 }
2077
2078 // If we're showing the description, add it.
2079 if (weatherSettings.showCondition) {
2080 let conditionTextStack = this.align(currentWeatherStack)
2081 let conditionText = this.provideText(weatherData.currentDescription, conditionTextStack, this.format.smallTemp)
2082 conditionTextStack.setPadding(this.padding, this.padding, 0, this.padding)
2083 }
2084
2085 // Add the temp vertically if it's not horizontal.
2086 if (!weatherSettings.horizontalCondition) {
2087 const tempStack = this.align(currentWeatherStack)
2088 tempStack.setPadding(0, this.padding, 0, this.padding)
2089 const tempText = this.displayNumber(weatherData.currentTemp,"--") + "°"
2090 const temp = this.provideText(tempText, tempStack, this.format.largeTemp)
2091 }
2092
2093 // If we're not showing the high and low, end it here.
2094 if (!weatherSettings.showHighLow) { return }
2095
2096 // Show the temp bar and high/low values.
2097 let tempBarStack = this.align(currentWeatherStack)
2098 tempBarStack.layoutVertically()
2099 tempBarStack.setPadding(0, this.padding, this.padding, this.padding)
2100
2101 let tempBar = this.drawTempBar()
2102 let tempBarImage = tempBarStack.addImage(tempBar)
2103 tempBarImage.size = new Size(50,0)
2104
2105 tempBarStack.addSpacer(1)
2106
2107 let highLowStack = tempBarStack.addStack()
2108 highLowStack.layoutHorizontally()
2109
2110 const mainLowText = this.displayNumber(weatherData.todayLow,"-")
2111 const mainLow = this.provideText(mainLowText, highLowStack, this.format.tinyTemp)
2112 highLowStack.addSpacer()
2113 const mainHighText = this.displayNumber(weatherData.todayHigh,"-")
2114 const mainHigh = this.provideText(mainHighText, highLowStack, this.format.tinyTemp)
2115
2116 tempBarStack.size = new Size(60,30)
2117 },
2118
2119 // Display upcoming weather.
2120 async future(column) {
2121
2122 // Requirements: location, weather, and sunrise
2123 if (!this.data.location) { await this.setupLocation() }
2124 if (!this.data.weather) { await this.setupWeather() }
2125 if (!this.data.sun) { await this.setupSunrise() }
2126
2127 // Get the relevant data and weather settings.
2128 const [locationData, weatherData, sunData] = [this.data.location, this.data.weather, this.data.sun]
2129 const weatherSettings = this.settings.weather
2130
2131 // Set up the future weather stack.
2132 let futureWeatherStack = column.addStack()
2133 futureWeatherStack.layoutVertically()
2134 futureWeatherStack.setPadding(0, 0, 0, 0)
2135
2136 const defaultUrl = "https://weather.com/" + this.locale + "/weather/tenday/l/" + locationData.latitude + "," + locationData.longitude
2137 const settingUrl = weatherSettings.urlFuture || ""
2138 futureWeatherStack.url = (settingUrl.length > 0) ? settingUrl : defaultUrl
2139
2140 // Determine if we should show the next hour.
2141 const showNextHour = (this.now.getHours() < parseInt(weatherSettings.tomorrowShownAtHour))
2142
2143 // Set the label value.
2144 const subLabelStack = this.align(futureWeatherStack)
2145 const subLabelText = showNextHour ? this.localization.nextHourLabel : this.localization.tomorrowLabel
2146 const subLabel = this.provideText(subLabelText, subLabelStack, this.format.smallTemp)
2147 subLabelStack.setPadding(0, this.padding, this.padding/2, this.padding)
2148
2149 // Set up the sub condition stack.
2150 let subConditionStack = this.align(futureWeatherStack)
2151 subConditionStack.layoutHorizontally()
2152 subConditionStack.centerAlignContent()
2153 subConditionStack.setPadding(0, this.padding, this.padding, this.padding)
2154
2155 // Determine if it will be night in the next hour.
2156 var nightCondition
2157 if (showNextHour) {
2158 const addHour = this.now.getTime() + (60*60*1000)
2159 const newDate = new Date(addHour)
2160 nightCondition = this.isNight(newDate)
2161 } else {
2162 nightCondition = false
2163 }
2164
2165 let subCondition = subConditionStack.addImage(this.provideConditionSymbol(showNextHour ? weatherData.nextHourCondition : weatherData.forecast[1].Condition,nightCondition))
2166 const subConditionSize = showNextHour ? 14 : 18
2167 subCondition.imageSize = new Size(subConditionSize, subConditionSize)
2168 this.tintIcon(subCondition, this.format.smallTemp)
2169 subConditionStack.addSpacer(5)
2170
2171 // The next part of the display changes significantly for next hour vs tomorrow.
2172 let rainPercent
2173 if (showNextHour) {
2174 const subTempText = this.displayNumber(weatherData.nextHourTemp,"--") + "°"
2175 const subTemp = this.provideText(subTempText, subConditionStack, this.format.smallTemp)
2176 rainPercent = weatherData.nextHourRain
2177
2178 } else {
2179 let tomorrowLine = subConditionStack.addImage(this.drawVerticalLine(new Color((this.format.tinyTemp && this.format.tinyTemp.color) ? this.format.tinyTemp.color : this.format.defaultText.color, 0.5), 20))
2180 tomorrowLine.imageSize = new Size(3,28)
2181 subConditionStack.addSpacer(5)
2182 let tomorrowStack = subConditionStack.addStack()
2183 tomorrowStack.layoutVertically()
2184
2185 const tomorrowHighText = this.displayNumber(weatherData.forecast[1].High,"-")
2186 const tomorrowHigh = this.provideText(tomorrowHighText, tomorrowStack, this.format.tinyTemp)
2187 tomorrowStack.addSpacer(4)
2188 const tomorrowLowText = this.displayNumber(weatherData.forecast[1].Low,"-")
2189 const tomorrowLow = this.provideText(tomorrowLowText, tomorrowStack, this.format.tinyTemp)
2190 rainPercent = (weatherData.tomorrowRain == null ? "--" : weatherData.tomorrowRain*100)
2191 }
2192
2193 // If we're showing rain percentage, add it.
2194 if (weatherSettings.showRain) {
2195 let subRainStack = this.align(futureWeatherStack)
2196 subRainStack.layoutHorizontally()
2197 subRainStack.centerAlignContent()
2198 subRainStack.setPadding(0, this.padding, this.padding, this.padding)
2199
2200 let subRain = subRainStack.addImage(SFSymbol.named("umbrella").image)
2201 const subRainSize = showNextHour ? 14 : 18
2202 subRain.imageSize = new Size(subRainSize, subRainSize)
2203 subRain.tintColor = new Color((this.format.smallTemp && this.format.smallTemp.color) ? this.format.smallTemp.color : this.format.defaultText.color)
2204 subRainStack.addSpacer(5)
2205
2206 const subRainText = this.displayNumber(rainPercent,"--") + "%"
2207 this.provideText(subRainText, subRainStack, this.format.smallTemp)
2208 }
2209 },
2210
2211 // Display forecast weather.
2212 async forecast(column) {
2213
2214 // Requirements: location, weather, and sunrise
2215 if (!this.data.location) { await this.setupLocation() }
2216 if (!this.data.weather) { await this.setupWeather() }
2217 if (!this.data.sun) { await this.setupSunrise() }
2218
2219 // Get the relevant data and weather settings.
2220 const [locationData, weatherData, sunData] = [this.data.location, this.data.weather, this.data.sun]
2221 const weatherSettings = this.settings.weather
2222
2223 let startIndex = weatherSettings.showToday ? 1 : 2
2224 let endIndex = parseInt(weatherSettings.showDays) + startIndex
2225 if (endIndex > 9) { endIndex = 9 }
2226
2227 const defaultUrl = "https://weather.com/" + this.locale + "/weather/tenday/l/" + locationData.latitude + "," + locationData.longitude
2228 const settingUrl = weatherSettings.urlForecast || ""
2229 const urlToUse = (settingUrl.length > 0) ? settingUrl : defaultUrl
2230 const spacing = weatherSettings.spacing ? parseInt(weatherSettings.spacing) : 0
2231
2232 for (var i=startIndex; i < endIndex; i++) {
2233 // Set up the today weather stack.
2234 let weatherStack = column.addStack()
2235 weatherStack.layoutVertically()
2236 weatherStack.setPadding(spacing, 0, spacing, 0)
2237 weatherStack.url = urlToUse
2238
2239 // Set up the date formatter and set its locale.
2240 let df = new DateFormatter()
2241 df.locale = this.locale
2242
2243 // Set up the sub condition stack.
2244 let subConditionStack = this.align(weatherStack)
2245 var myDate = new Date();
2246 myDate.setDate(this.now.getDate() + (i - 1));
2247 df.dateFormat = weatherSettings.showDaysFormat
2248
2249 let dateStack = subConditionStack.addStack()
2250 dateStack.layoutHorizontally()
2251 dateStack.setPadding(0, 0, 0, 0)
2252
2253 let dateText = this.provideText(df.string(myDate), dateStack, this.format.smallTemp)
2254 dateText.lineLimit = 1
2255 dateText.minimumScaleFactor = 0.5
2256 dateStack.addSpacer()
2257 let fontSize = (this.format.smallTemp && this.format.smallTemp.size) ? this.format.smallTemp.size : this.format.defaultText.size
2258 dateStack.size = new Size(fontSize*2.64,0)
2259 subConditionStack.addSpacer(5)
2260 subConditionStack.layoutHorizontally()
2261 subConditionStack.centerAlignContent()
2262 subConditionStack.setPadding(0, this.padding, this.padding, this.padding)
2263
2264 let subCondition = subConditionStack.addImage(this.provideConditionSymbol(weatherData.forecast[i - 1].Condition, false))
2265 subCondition.imageSize = new Size(18, 18)
2266 this.tintIcon(subCondition, this.format.smallTemp)
2267 subConditionStack.addSpacer(5)
2268
2269 let tempLine = subConditionStack.addImage(this.drawVerticalLine(new Color((this.format.tinyTemp && this.format.tinyTemp.color) ? this.format.tinyTemp.color : this.format.defaultText.color, 0.5), 20))
2270 tempLine.imageSize = new Size(3,28)
2271 subConditionStack.addSpacer(5)
2272 let tempStack = subConditionStack.addStack()
2273 tempStack.layoutVertically()
2274
2275 const tempHighText = this.displayNumber(weatherData.forecast[i - 1].High,"-")
2276 const tempHigh = this.provideText(tempHighText, tempStack, this.format.tinyTemp)
2277 tempStack.addSpacer(4)
2278 const tempLowText = this.displayNumber(weatherData.forecast[i - 1].Low,"-")
2279 const tempLow = this.provideText(tempLowText, tempStack, this.format.tinyTemp)
2280 }
2281 },
2282
2283 // Add a battery element to the widget.
2284 async battery(column) {
2285
2286 // Set up the battery level item.
2287 const batteryStack = this.align(column)
2288 batteryStack.layoutHorizontally()
2289 batteryStack.centerAlignContent()
2290 batteryStack.setPadding(this.padding/2, this.padding, this.padding/2, this.padding)
2291
2292 // Set up the battery icon.
2293 const batteryIcon = batteryStack.addImage(this.provideBatteryIcon(Device.batteryLevel(),Device.isCharging()))
2294 batteryIcon.imageSize = new Size(30,30)
2295
2296 // Change the battery icon to red if battery level is less than 20%.
2297 const batteryLevel = Math.round(Device.batteryLevel() * 100)
2298 if (batteryLevel > 20 || Device.isCharging() ) {
2299 this.tintIcon(batteryIcon,this.format.battery,true)
2300 } else {
2301 batteryIcon.tintColor = Color.red()
2302 }
2303
2304 // Format the rest of the item.
2305 batteryStack.addSpacer(this.padding * 0.6)
2306 this.provideText(batteryLevel + "%", batteryStack, this.format.battery)
2307 },
2308
2309 // Show the sunrise or sunset time.
2310 async sunrise(column, showSunset = false) {
2311
2312 // Requirements: sunrise
2313 if (!this.data.sun) { await this.setupSunrise() }
2314
2315 // Get the sunrise data and settings.
2316 const sunData = this.data.sun
2317 const sunSettings = this.settings.sunrise
2318
2319 const sunrise = sunData.sunrise
2320 const sunset = sunData.sunset
2321 const tomorrow = sunData.tomorrow
2322 const current = this.now.getTime()
2323
2324 const showWithin = parseInt(sunSettings.showWithin)
2325 const nearSunrise = this.closeTo(sunrise) <= showWithin
2326 const nearSunset = this.closeTo(sunset) <= showWithin
2327
2328 // If we only show sometimes and we're not close, return.
2329 if (showWithin > 0 && !nearSunrise && !nearSunset) { return }
2330
2331 // Otherwise, determine which time to show.
2332 let timeToShow, symbolName
2333 const halfHour = 30 * 60 * 1000
2334
2335 // Determine logic for when to show sunset for a combined element.
2336 const combinedSunset = current > sunrise + halfHour && current < sunset + halfHour
2337
2338 // Determine if we should show the sunset.
2339 if (sunSettings.separateElements ? showSunset : combinedSunset) {
2340 symbolName = "sunset.fill"
2341 timeToShow = sunset
2342 }
2343
2344 // Otherwise, show a sunrise.
2345 else {
2346 symbolName = "sunrise.fill"
2347 timeToShow = current > sunset ? tomorrow : sunrise
2348 }
2349
2350 // Set up the stack.
2351 const sunriseStack = this.align(column)
2352 sunriseStack.setPadding(this.padding/2, this.padding, this.padding/2, this.padding)
2353 sunriseStack.layoutHorizontally()
2354 sunriseStack.centerAlignContent()
2355
2356 sunriseStack.addSpacer(this.padding * 0.3)
2357
2358 // Add the correct symbol.
2359 const symbol = sunriseStack.addImage(SFSymbol.named(symbolName).image)
2360 symbol.imageSize = new Size(22,22)
2361 this.tintIcon(symbol, this.format.sunrise)
2362
2363 sunriseStack.addSpacer(this.padding)
2364
2365 // Add the time.
2366 const timeText = this.formatTime(new Date(timeToShow))
2367 const time = this.provideText(timeText, sunriseStack, this.format.sunrise)
2368 },
2369
2370 // Allow for either term to be used.
2371 async sunset(column) {
2372 return await this.sunrise(column, true)
2373 },
2374
2375 // Add custom text to the column.
2376 text(column, input) {
2377
2378 // If there was no input, don't do anything.
2379 if (!input || input == "") { return }
2380
2381 // Otherwise, add the text.
2382 const textStack = this.align(column)
2383 textStack.setPadding(this.padding, this.padding, this.padding, this.padding)
2384 const textDisplay = this.provideText(input, textStack, this.format.customText)
2385
2386 },
2387
2388 // Display COVID info on the widget.
2389 async covid(column) {
2390
2391 // Requirements: sunrise
2392 if (!this.data.covid) { await this.setupCovid() }
2393
2394 // Get the sunrise data and settings.
2395 const covidData = this.data.covid
2396 const covidSettings = this.settings.covid
2397
2398 // Set up the stack.
2399 const covidStack = this.align(column)
2400 covidStack.setPadding(this.padding/2, this.padding, this.padding/2, this.padding)
2401 covidStack.layoutHorizontally()
2402 covidStack.centerAlignContent()
2403 covidStack.url = covidSettings.url
2404
2405 covidStack.addSpacer(this.padding * 0.3)
2406
2407 // Add the correct symbol.
2408 const symbol = covidStack.addImage(SFSymbol.named("bandage").image)
2409 symbol.imageSize = new Size(18,18)
2410 this.tintIcon(symbol,this.format.covid,true)
2411
2412 covidStack.addSpacer(this.padding)
2413
2414 // Add the COVID information.
2415 const locale = this.locale
2416 const covidText = this.localization.covid.replace(/{(.*?)}/g, (match, $1) => {
2417 let val = covidData[$1]
2418 if (val) val = new Intl.NumberFormat(locale.replace('_','-')).format(val)
2419 return val || ""
2420 })
2421 this.provideText(covidText, covidStack, this.format.covid)
2422
2423 },
2424
2425 // Display week number for current date.
2426 async week(column) {
2427
2428 // Set up the stack.
2429 const weekStack = this.align(column)
2430 weekStack.setPadding(this.padding/2, this.padding, 0, this.padding)
2431 weekStack.layoutHorizontally()
2432 weekStack.centerAlignContent()
2433
2434 // Add the week information.
2435 var currentThursday = new Date(this.now.getTime() +(3-((this.now.getDay()+6) % 7)) * 86400000)
2436 var yearOfThursday = currentThursday.getFullYear()
2437 var firstThursday = new Date(new Date(yearOfThursday,0,4).getTime() +(3-((new Date(yearOfThursday,0,4).getDay()+6) % 7)) * 86400000)
2438 var weekNumber = Math.floor(1 + 0.5 + (currentThursday.getTime() - firstThursday.getTime()) / 86400000/7) + ""
2439 var weekText = this.localization.week + " " + weekNumber
2440 this.provideText(weekText, weekStack, this.format.week)
2441 },
2442
2443 /*
2444 * HELPER FUNCTIONS
2445 * These functions perform duties for other functions.
2446 * ===================================================
2447 */
2448
2449 // Returns a rounded number string or the provided dummy text.
2450 displayNumber(number,dummy = "-") {
2451 return (number == null ? dummy : Math.round(number).toString())
2452 },
2453
2454 // Tints icons if needed or forced.
2455 tintIcon(icon,format,force = false) {
2456 // Don't tint if the setting is off and we're not forced.
2457 if (!this.tintIcons && !force) { return }
2458 icon.tintColor = new Color((format && format.color) ? format.color : this.format.defaultText.color)
2459 },
2460
2461 // Determines if the provided date is at night.
2462 isNight(dateInput) {
2463 const timeValue = dateInput.getTime()
2464 return (timeValue < this.data.sun.sunrise) || (timeValue > this.data.sun.sunset)
2465 },
2466
2467 // Determines if two dates occur on the same day.
2468 sameDay(d1, d2) {
2469 return d1.getFullYear() === d2.getFullYear() &&
2470 d1.getMonth() === d2.getMonth() &&
2471 d1.getDate() === d2.getDate()
2472 },
2473
2474 // Returns the number of minutes between now and the provided date.
2475 closeTo(time) {
2476 return Math.abs(this.now.getTime() - time) / 60000
2477 },
2478
2479 // Format the time for a Date input.
2480 formatTime(date) {
2481 let df = new DateFormatter()
2482 df.locale = this.locale
2483 df.useNoDateStyle()
2484 df.useShortTimeStyle()
2485 return df.string(date)
2486 },
2487
2488 // Provide a text symbol with the specified shape.
2489 provideTextSymbol(shape) {
2490
2491 // Rectangle character.
2492 if (shape.startsWith("rect")) {
2493 return "\u2759"
2494 }
2495 // Circle character.
2496 if (shape == "circle") {
2497 return "\u2B24"
2498 }
2499 // Default to the rectangle.
2500 return "\u2759"
2501 },
2502
2503 // Provide a battery SFSymbol with accurate level drawn on top of it.
2504 provideBatteryIcon(batteryLevel,charging = false) {
2505
2506 // If we're charging, show the charging icon.
2507 if (charging) { return SFSymbol.named("battery.100.bolt").image }
2508
2509 // Set the size of the battery icon.
2510 const batteryWidth = 87
2511 const batteryHeight = 41
2512
2513 // Start our draw context.
2514 let draw = new DrawContext()
2515 draw.opaque = false
2516 draw.respectScreenScale = true
2517 draw.size = new Size(batteryWidth, batteryHeight)
2518
2519 // Draw the battery.
2520 draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
2521
2522 // Match the battery level values to the SFSymbol.
2523 const x = batteryWidth*0.1525
2524 const y = batteryHeight*0.247
2525 const width = batteryWidth*0.602
2526 const height = batteryHeight*0.505
2527
2528 // Prevent unreadable icons.
2529 let level = batteryLevel
2530 if (level < 0.05) { level = 0.05 }
2531
2532 // Determine the width and radius of the battery level.
2533 const current = width * level
2534 let radius = height/6.5
2535
2536 // When it gets low, adjust the radius to match.
2537 if (current < (radius * 2)) { radius = current / 2 }
2538
2539 // Make the path for the battery level.
2540 let barPath = new Path()
2541 barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
2542 draw.addPath(barPath)
2543 draw.setFillColor(Color.black())
2544 draw.fillPath()
2545 return draw.getImage()
2546 },
2547
2548 // Provide a symbol based on the condition.
2549 provideConditionSymbol(cond,night) {
2550
2551 // Define our symbol equivalencies.
2552 let symbols = {
2553
2554 // Error
2555 "1": function() { return "exclamationmark.circle" },
2556
2557 // Thunderstorm
2558 "2": function() { return "cloud.bolt.rain.fill" },
2559
2560 // Drizzle
2561 "3": function() { return "cloud.drizzle.fill" },
2562
2563 // Rain
2564 "5": function() { return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
2565
2566 // Snow
2567 "6": function() { return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
2568
2569 // Atmosphere
2570 "7": function() {
2571 if (cond == 781) { return "tornado" }
2572 if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
2573 return night ? "cloud.fog.fill" : "sun.haze.fill"
2574 },
2575
2576 // Clear and clouds
2577 "8": function() {
2578 if (cond == 800 || cond == 801) { return night ? "moon.stars.fill" : "sun.max.fill" }
2579 if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" }
2580 return "cloud.fill"
2581 }
2582 }
2583
2584 // Find out the first digit.
2585 let conditionDigit = Math.floor(cond / 100)
2586
2587 // Get the symbol.
2588 return SFSymbol.named(symbols[conditionDigit]()).image
2589 },
2590
2591 // Provide a font based on the input.
2592 provideFont(fontName, fontSize) {
2593 const fontGenerator = {
2594 ultralight() { return Font.ultraLightSystemFont(fontSize) },
2595 light() { return Font.lightSystemFont(fontSize) },
2596 regular() { return Font.regularSystemFont(fontSize) },
2597 medium() { return Font.mediumSystemFont(fontSize) },
2598 semibold() { return Font.semiboldSystemFont(fontSize) },
2599 bold() { return Font.boldSystemFont(fontSize) },
2600 heavy() { return Font.heavySystemFont(fontSize) },
2601 black() { return Font.blackSystemFont(fontSize) },
2602 italic() { return Font.italicSystemFont(fontSize) },
2603 }
2604
2605 const systemFont = fontGenerator[fontName]
2606 if (systemFont) { return systemFont() }
2607 return new Font(fontName, fontSize)
2608 },
2609
2610 // Add formatted text to a container.
2611 provideText(string, container, format) {
2612 const defaultText = this.format.defaultText
2613 const textItem = container.addText(string)
2614 const textFont = (format && format.font) ? format.font : defaultText.font
2615 const textSize = (format && format.size) ? format.size : defaultText.size
2616 const textColor = (format && format.color) ? format.color : defaultText.color
2617
2618 textItem.font = this.provideFont(textFont, parseInt(textSize))
2619 textItem.textColor = new Color(textColor)
2620 return textItem
2621 },
2622
2623 /*
2624 * DRAWING FUNCTIONS
2625 * These functions draw onto a canvas.
2626 * ===================================
2627 */
2628
2629 // Draw the vertical line in the tomorrow view.
2630 drawVerticalLine(color, height) {
2631
2632 const width = 2
2633
2634 let draw = new DrawContext()
2635 draw.opaque = false
2636 draw.respectScreenScale = true
2637 draw.size = new Size(width,height)
2638
2639 let barPath = new Path()
2640 const barHeight = height
2641 barPath.addRoundedRect(new Rect(0, 0, width, height), width/2, width/2)
2642 draw.addPath(barPath)
2643 draw.setFillColor(color)
2644 draw.fillPath()
2645
2646 return draw.getImage()
2647 },
2648
2649 // Draw the temp bar.
2650 drawTempBar() {
2651
2652 // Set the size of the temp bar.
2653 const tempBarWidth = 200
2654 const tempBarHeight = 20
2655
2656 // Get the weather data.
2657 const weatherData = this.data.weather
2658
2659 // Calculate the current percentage of the high-low range.
2660 let percent = (weatherData.currentTemp - weatherData.todayLow) / (weatherData.todayHigh - weatherData.todayLow)
2661
2662 // If we're out of bounds, clip it.
2663 if (percent < 0) {
2664 percent = 0
2665 } else if (percent > 1) {
2666 percent = 1
2667 }
2668
2669 // Determine the scaled x-value for the current temp.
2670 const currPosition = (tempBarWidth - tempBarHeight) * percent
2671
2672 // Start our draw context.
2673 let draw = new DrawContext()
2674 draw.opaque = false
2675 draw.respectScreenScale = true
2676 draw.size = new Size(tempBarWidth, tempBarHeight)
2677
2678 // Make the path for the bar.
2679 let barPath = new Path()
2680 const barHeight = tempBarHeight - 10
2681 barPath.addRoundedRect(new Rect(0, 5, tempBarWidth, barHeight), barHeight / 2, barHeight / 2)
2682 draw.addPath(barPath)
2683
2684 // Determine the color.
2685 const barColor = (this.format.tinyTemp && this.format.tinyTemp.color) ? this.format.tinyTemp.color : this.format.defaultText.color
2686 draw.setFillColor(new Color(barColor, 0.5))
2687 draw.fillPath()
2688
2689 // Make the path for the current temp indicator.
2690 let currPath = new Path()
2691 currPath.addEllipse(new Rect(currPosition, 0, tempBarHeight, tempBarHeight))
2692 draw.addPath(currPath)
2693 draw.setFillColor(new Color(barColor, 1))
2694 draw.fillPath()
2695
2696 return draw.getImage()
2697 },
2698}
2699
2700// Store the Weather Cal object in the exports.
2701module.exports = weatherCal
2702
2703/*
2704 * TESTING
2705 * Un-comment to test Weather Cal.
2706 * ===============================
2707 */
2708
2709// const name = "Weather Cal widget"
2710// const iCloudInUse = true
2711// const codeFilename = "Weather Cal code"
2712// const gitHubUrl = "https://raw.githubusercontent.com/mzeryck/Weather-Cal/main/weather-cal-code.js"
2713// const layout = `
2714// row
2715// column
2716// battery
2717// covid
2718// date
2719// events
2720// greeting
2721// reminders
2722// sunrise
2723// sunset
2724// text(Hello)
2725// week
2726//
2727// column(90)
2728// current
2729// future
2730// forecast `
2731//
2732// await weatherCal.runSetup(name, iCloudInUse, codeFilename, gitHubUrl)
2733//
2734// let w = await weatherCal.createWidget(layout, name, iCloudInUse)
2735// w.presentLarge()
2736
2737Script.complete()
2738