Last active
October 1, 2024 06:41
-
-
Save thoukydides/9caf3aae0a2ebd96d1a2a387e34bf691 to your computer and use it in GitHub Desktop.
An iOS Scriptable widget to display South Cambridgeshire bin collection dates
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: green; icon-glyph: trash-alt; | |
// South Cambridgeshire bin collection widget from iOS Scriptable | |
// Copyright © 2020 Alexander Thoukydides | |
'use strict'; | |
// South Cambridgeshire District Council API configuration | |
// (reverse engineered from https://www.scambs.gov.uk/Scripts/bin-calendar.js) | |
const WEB_URL = 'https://www.scambs.gov.uk/bins/find-your-household-bin-collection-day/#id=' | |
const API_URL = 'https://servicelayer3c.azure-api.net/wastecalendar/'; | |
// Keychain key for storing the address identifier | |
const ID_KEY = 'SCDC Address ID'; | |
// Retrieve any saved address identifier | |
let id; | |
if (Keychain.contains(ID_KEY)) id = Keychain.get(ID_KEY); | |
// Action depends on whether the script is being run within a Widge | |
if (config.runsInWidget) { | |
// Check that an address has been selected | |
if (!id) throw new Error('Run this script within Scriptable to select address'); | |
// Create and display the widget | |
let widget = await createWidget(id, config.widgetFamily); | |
Script.setWidget(widget); | |
} else { | |
// Require an address to be selected on first use | |
if (!id) await selectAddress(); | |
// Allow the widget to be previewed or the address changed | |
let index = id ? 0 : -1; | |
while (index != -1) { | |
// Construct a menu of available options | |
let alert = new Alert(); | |
alert.title = Script.name(); | |
let address = await getAddress(id); | |
alert.message = address; | |
let actions = [ | |
{ text: 'Preview widget: Small', action: () => previewWidget('small') }, | |
{ text: 'Preview widget: Medium', action: () => previewWidget('medium') }, | |
{ text: 'Preview widget: Large', action: () => previewWidget('large') }, | |
{ text: 'Change address', action: () => selectAddress(), destructive: true } | |
]; | |
for (let action of actions) { | |
if (action.destructive) alert.addDestructiveAction(action.text); | |
else alert.addAction(action.text); | |
} | |
alert.addCancelAction('Cancel'); | |
// Display the menu and perform the selected action | |
index = await alert.presentAlert(); | |
if (index != -1) await actions[index].action(); | |
} | |
} | |
// That's all folks! | |
Script.complete(); | |
// Get a new address and store it in the Keychain | |
async function selectAddress() { | |
// Input a postcode | |
let postcodeAlert = new Alert(); | |
postcodeAlert.title = Script.name(); | |
postcodeAlert.message = 'Enter postcode'; | |
postcodeAlert.addTextField('postcode'); | |
postcodeAlert.addAction('Lookup postcode'); | |
postcodeAlert.addCancelAction('Cancel'); | |
let postcodeIndex = await postcodeAlert.presentAlert(); | |
if (postcodeIndex == -1) return; | |
let postcode = postcodeAlert.textFieldValue(0).trim(); | |
// Offer a list of matching addresses | |
console.log(postcode); | |
let addresses = await getAddresses(postcode); | |
let addressAlert = new Alert(); | |
addressAlert.title = Script.name(); | |
addressAlert.message = 'Select address'; | |
for (let address of Object.values(addresses)) { | |
addressAlert.addAction(address); | |
} | |
addressAlert.addCancelAction('Cancel'); | |
let addressIndex = await addressAlert.presentAlert(); | |
if (addressIndex == -1) return; | |
id = Object.keys(addresses)[addressIndex]; | |
// Save the address identifier in the Keychain | |
Keychain.set(ID_KEY, id); | |
} | |
// Lookup addresses with a particular postcode | |
async function getAddresses(postcode) { | |
let url = API_URL + 'address/search/?postCode=' + encodeURIComponent(postcode); | |
let request = new Request(url); | |
let result = await request.loadJSON(); | |
console.log(result); | |
return Object.fromEntries(result.map(address => [address.id, formatAddress(address)])); | |
} | |
// Lookup the address for a particular identifier | |
async function getAddress(id) { | |
let url = API_URL + 'address/search/?id=' + id; | |
let request = new Request(url); | |
let result = await request.loadJSON(); | |
console.log(result); | |
return formatAddress(result); | |
} | |
// Tidy an address returned by the API | |
function formatAddress(address) { | |
let street = [address.houseNumber + ' ' + address.street].join(' '); | |
let postcode = address.postCode.slice(0, -3) + ' ' + address.postCode.slice(-3); | |
return [titleCase(street), titleCase(address.town), postcode].join(', '); | |
} | |
// Get details of upcoming collections | |
async function getCollections(id, maxResults) { | |
let url = API_URL + 'collection/search/' + id + '/?numberOfCollections=' + maxResults; | |
let request = new Request(url); | |
let result = await request.loadJSON(); | |
console.log(result); | |
return result.collections; | |
} | |
// Get URL for the web page | |
function getWebURL(id) { | |
return WEB_URL + id; | |
} | |
// Create and preview the widget | |
async function previewWidget(size) { | |
let widget = await createWidget(id, size); | |
switch (size) { | |
case 'small': await widget.presentSmall(); break; | |
case 'medium': await widget.presentMedium(); break; | |
case 'large': await widget.presentLarge(); break; | |
} | |
} | |
// Create the widget | |
async function createWidget(id, size) { | |
// Retrieve details of the upcoming bin collections | |
let collections = await getCollections(id, 3); | |
// Create the widget | |
let widget = new ListWidget(); | |
// Add details of the next bin collection | |
let next = collections[0]; | |
let nextBits = describeCollection(next); | |
if (size != 'small') nextBits.bins += ' will be collected'; | |
let nextText = widget.addText(nextBits.bins); | |
nextText.font = Font.mediumSystemFont(14); | |
nextText.textColor = Color.black(); | |
nextText.centerAlignText(); | |
widget.addSpacer(); | |
// Date of next bin collection | |
let dayText = widget.addText(nextBits.day); | |
dayText.font = Font.boldSystemFont(28); | |
dayText.textColor = next.slippedCollection ? Color.red() : Color.yellow(); | |
dayText.centerAlignText(); | |
let dateText = widget.addText(nextBits.date); | |
dateText.font = Font.mediumSystemFont(14); | |
dateText.textColor = Color.white(); | |
dateText.centerAlignText(); | |
widget.addSpacer(); | |
// Add details of the following bin collection | |
if (size != 'small') { | |
let after = collections[1]; | |
let afterBits = describeCollection(after); | |
let afterText = widget.addText(afterBits.bins + ' collected ' + afterBits.day + ' ' + afterBits.date); | |
afterText.font = Font.lightSystemFont(12); | |
afterText.textColor = after.slippedCollection ? Color.red() : Color.black(); | |
afterText.centerAlignText(); | |
} | |
// Pick a colour scheme for the widget | |
widget.backgroundColor = Color.black(); | |
let gradient = new LinearGradient(); | |
gradient.colors = coloursCollection(next); | |
gradient.locations = [0, 1]; | |
widget.backgroundGradient = gradient; | |
// Set the URL to open if the widget is clicked | |
widget.url = getWebURL(id); | |
// Only need to refresh once a day | |
let today = new Date(); | |
let tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); | |
widget.refreshAfterDate = tomorrow; | |
// Return the widget | |
return widget; | |
} | |
// Textual description of a bin collection | |
function describeCollection(collection) { | |
// Describe the bins being collected | |
const ROUND_NAMES = { ORGANIC: 'Green', RECYCLE: 'Blue', DOMESTIC: 'Black' }; | |
let names = collection.roundTypes.map(key => ROUND_NAMES[key]); | |
let binsText = names.join(' & ') + (names.length == 1 ? ' bin' : ' bins'); | |
// Convert the collection date to text | |
let date = new Date(collection.date); | |
let dayText = isToday(date) ? 'TODAY' | |
: (isTomorrow(date) ? 'TOMORROW' | |
: date.toLocaleDateString(undefined, { weekday: 'long' })); | |
let dateText = date.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); | |
// Return the results | |
return { bins: binsText, day: dayText, date: dateText }; | |
} | |
// Colours for a bin collection | |
function coloursCollection(collection) { | |
const ROUND_COLOURS = { ORGANIC: '#77DD66', RECYCLE: '#6677FF', DOMESTIC: '#888888' }; | |
let hexColours = collection.roundTypes.map(key => ROUND_COLOURS[key]); | |
let colours = hexColours.map(hex => new Color(hex)); | |
if (colours.length == 1) colours.push(new Color(hexColours[0], 0.5)); | |
return colours; | |
} | |
// Convert a string to title case | |
function titleCase(text) { | |
return text.toLowerCase().replaceAll(/\b\w/g, letter => letter.toUpperCase()); | |
} | |
// Date comparisons | |
function isToday(date) { | |
let today = new Date(); | |
return isSameDay(date, today); | |
} | |
function isTomorrow(date) { | |
let tomorrow = new Date(); | |
tomorrow.setDate(tomorrow.getDate() + 1); | |
return isSameDay(date, tomorrow); | |
} | |
function isSameDay(date1, date2) { | |
return date1.getFullYear() == date2.getFullYear() | |
&& date1.getMonth() == date2.getMonth() | |
&& date1.getDate() == date2.getDate(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment