Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save goldfarbalex/a7e9394ef47768d4c16d4c916570c611 to your computer and use it in GitHub Desktop.
Save goldfarbalex/a7e9394ef47768d4c16d4c916570c611 to your computer and use it in GitHub Desktop.
Customizable iOS Calendar widget in Scriptable

Scriptable Calendar Widget

scriptable calendar

Instructions

  • Copy the script under Code section of this document to a new script in Scriptable app.
  • Run the script first which should prompt Scriptable to ask for calendar access.
    • if it didn't and you haven't given Scriptable calendar access before, try changing the debug variable to true and trying again.
    • to have the widget open the iOS calendar app, switch debug back to false afterwards.
  • Add a medium sized Scriptable widget to your homescreen.
  • Long press the widget and choose "Edit Widget".
  • Set the Script to be the script you just created and When Interacting to Run Script which will then launch Calendar app when you tap on the widget.
  • Return to your home screen which should now hopefully show the Scriptable calendar widget.

  • the beginning of the script shows the hex colors for various parts which can be modified to your liking:
    • background color
    • current day color
    • text color
  • To get an image that can then be used to have a "transparent" widget background use this script and save it to the Scriptable folder on iCloud. Then set either the widget parameter (long press on the widget -> edit widget -> parameter) to { "bg": "my-image.jpg"} where my-image is the name of your transparent backgorund OR change the line which has { bg: "1121.jpg" } to include your image name.

Small widgets

scriptable calendar

The script also supports small widgets or a medium widget with just one part. In this case the the widget parameter (long press on the widget -> edit widget -> parameter) should be set to something like:

  • { "bg": "top-left.jpg", "view": "events" }
  • { "bg": "top-right.jpg", "view": "cal" }

Where "events" specifies the events view and "cal" the calendar view. (Setting the background is optional).

Code

You are free to use and modify the code however you wish. There are likely going to be bugs in this code as I have not tested it for every edge case.

// use true to initially give Scriptable calendar access
// use false to open Calendar when script is run - when tapping on the widget
const debug = false;

// get widget params
const params = JSON.parse(args.widgetParameter) || { bg: "1121.jpg" };
// a separate image can be specified per widget in widget params:
// Long press on widget -> Edit Widget -> Parameter
// parameter config would look like this:
// { "bg": "2111.jpg", "view": "events" }
const imageName = params.bg;
const backgroundColor = "#000000";
const currentDayColor = "#000000";
const textColor = "#ffffff";
// opacity value for weekends and times
const opacity = 0.7;
// choose either a split view or show only one of them
const showEventsView = params.view ? params.view === "events" : true;
const showCalendarView = params.view ? params.view === "cal" : true;

if (config.runsInWidget) {
  let widget = await createWidget();
  Script.setWidget(widget);
  Script.complete();
} else if (debug) {
  Script.complete();
  let widget = await createWidget();
  await widget.presentMedium();
} else {
  const appleDate = new Date("2001/01/01");
  const timestamp = (new Date().getTime() - appleDate.getTime()) / 1000;
  console.log(timestamp);
  const callback = new CallbackURL("calshow:" + timestamp);
  callback.open();
  Script.complete();
}

async function createWidget() {
  let widget = new ListWidget();
  widget.backgroundColor = new Color(backgroundColor);
  setWidgetBackground(widget, imageName);
  widget.setPadding(16, 16, 16, 16);

  // layout horizontally
  const globalStack = widget.addStack();

  if (showEventsView) {
    await buildEventsView(globalStack);
  }
  if (showCalendarView) {
    buildCalendarView(globalStack);
  }

  return widget;
}

/**
 * Builds the events view
 *
 * @param  {WidgetStack} stack - onto which the events view is built
 */
async function buildEventsView(stack) {
  const leftStack = stack.addStack();
  // push event view to the left
  stack.addSpacer();

  leftStack.layoutVertically();
  // center the whole left part of the widget
  leftStack.addSpacer();

  const date = new Date();

  // Find future events that aren't all day and aren't canceled
  const events = await CalendarEvent.today([]);
  const futureEvents = [];
  for (const event of events) {
    if (
      event.startDate.getTime() > date.getTime() &&
      !event.isAllDay &&
      !event.title.startsWith("Canceled:")
    ) {
      futureEvents.push(event);
    }
  }

  // if we have events today; else if we don't
  if (futureEvents.length !== 0) {
    // show the next 3 events at most
    const numEvents = futureEvents.length > 3 ? 3 : futureEvents.length;
    for (let i = 0; i < numEvents; i += 1) {
      formatEvent(leftStack, futureEvents[i], textColor, opacity);
      // don't add a spacer after the last event
      if (i < numEvents - 1) {
        leftStack.addSpacer(8);
      }
    }
  } else {
    addWidgetTextLine(leftStack, "No more events today", {
      color: textColor,
      opacity,
      font: Font.regularSystemFont(15),
      align: "left",
    });
  }
  // for centering
  leftStack.addSpacer();
}

/**
 * Builds the calendar view
 *
 * @param  {WidgetStack} stack - onto which the calendar is built
 */
function buildCalendarView(stack) {
  const rightStack = stack.addStack();
  rightStack.layoutVertically();

  const date = new Date();
  const dateFormatter = new DateFormatter();
  dateFormatter.dateFormat = "MMMM";

  // if calendar is on a small widget make it a bit smaller to fit
  const spacing = config.widgetFamily === "small" ? 18 : 20;

  // Current month line
  const monthLine = rightStack.addStack();
  // since dates are centered in their squares we need to add some space
  monthLine.addSpacer(4);
  addWidgetTextLine(monthLine, dateFormatter.string(date).toUpperCase(), {
    color: textColor,
    textSize: 14,
    font: Font.boldSystemFont(13),
  });

  // between the month name and the week calendar
  rightStack.addSpacer(5);

  const calendarStack = rightStack.addStack();
  calendarStack.spacing = 2;

  const month = buildMonthVertical();

  for (let i = 0; i < month.length; i += 1) {
    let weekdayStack = calendarStack.addStack();
    weekdayStack.layoutVertically();

    for (let j = 0; j < month[i].length; j += 1) {
      let dayStack = weekdayStack.addStack();
      dayStack.size = new Size(spacing, spacing);
      dayStack.centerAlignContent();

      if (month[i][j] === date.getDate().toString()) {
        const highlightedDate = getHighlightedDate(
          date.getDate().toString(),
          currentDayColor
        );
        dayStack.addImage(highlightedDate);
      } else {
        addWidgetTextLine(dayStack, `${month[i][j]}`, {
          color: textColor,
          opacity: i > 4 ? opacity : 1,
          font: Font.boldSystemFont(10),
          align: "center",
        });
      }
    }
  }
}

/**
 * Creates an array of arrays, where the inner arrays include the same weekdays
 * along with an identifier in 0 position
 * [
 *   [ 'M', ' ', '7', '14', '21', '28' ],
 *   [ 'T', '1', '8', '15', '22', '29' ],
 *   [ 'W', '2', '9', '16', '23', '30' ],
 *   ...
 * ]
 *
 * @returns {Array<Array<string>>}
 */
function buildMonthVertical() {
  const date = new Date();
  const firstDayStack = new Date(date.getFullYear(), date.getMonth(), 1);
  const lastDayStack = new Date(date.getFullYear(), date.getMonth() + 1, 0);

  const month = [["M"], ["T"], ["W"], ["T"], ["F"], ["S"], ["S"]];

  let dayStackCounter = 0;

  for (let i = 1; i < firstDayStack.getDay(); i += 1) {
    month[i - 1].push(" ");
    dayStackCounter = (dayStackCounter + 1) % 7;
  }

  for (let date = 1; date <= lastDayStack.getDate(); date += 1) {
    month[dayStackCounter].push(`${date}`);
    dayStackCounter = (dayStackCounter + 1) % 7;
  }

  const length = month.reduce(
    (acc, dayStacks) => (dayStacks.length > acc ? dayStacks.length : acc),
    0
  );
  month.forEach((dayStacks, index) => {
    while (dayStacks.length < length) {
      month[index].push(" ");
    }
  });

  return month;
}

/**
 * Draws a circle with a date on it for highlighting in calendar view
 *
 * @param  {string} date to draw into the circle
 *
 * @returns {Image} a circle with the date
 */
function getHighlightedDate(date) {
  const drawing = new DrawContext();
  drawing.respectScreenScale = true;
  const size = 50;
  drawing.size = new Size(size, size);
  drawing.opaque = false;
  drawing.setFillColor(new Color("#000000"));
  drawing.fillEllipse(new Rect(1, 1, size - 2, size - 2));
  drawing.setFont(Font.boldSystemFont(25));
  drawing.setTextAlignedCenter();
  drawing.setTextColor(new Color("#ffffff"));
  drawing.drawTextInRect(date, new Rect(0, 10, size, size));
  const currentDayImg = drawing.getImage();
  return currentDayImg;
}

/**
 * formats the event times into just hours
 *
 * @param  {Date} date
 *
 * @returns {string} time
 */
function formatTime(date) {
  let dateFormatter = new DateFormatter();
  dateFormatter.useNoDateStyle();
  dateFormatter.useShortTimeStyle();
  return dateFormatter.string(date);
}

/**
 * Adds a event name along with start and end times to widget stack
 *
 * @param  {WidgetStack} stack - onto which the event is added
 * @param  {CalendarEvent} event - an event to add on the stack
 * @param  {number} opacity - text opacity
 */
function formatEvent(stack, event, color, opacity) {
  addWidgetTextLine(stack, event.title, {
    color,
    font: Font.mediumSystemFont(14),
    lineLimit: 1,
  });

  // create line for event start and end times
  let timeStack = stack.addStack();
  const time = `${formatTime(event.startDate)} - ${formatTime(event.endDate)}`;
  addWidgetTextLine(timeStack, time, {
    color,
    opacity,
    font: Font.regularSystemFont(14),
  });
}

function addWidgetTextLine(
  widget,
  text,
  {
    color = "#ffffff",
    textSize = 12,
    opacity = 1,
    align,
    font = "",
    lineLimit = 0,
  }
) {
  let textLine = widget.addText(text);
  textLine.textColor = new Color(color);
  if (typeof font === "string") {
    textLine.font = new Font(font, textSize);
  } else {
    textLine.font = font;
  }
  console.log(`${text}`);
  console.log(`${typeof opacity}`);
  textLine.textOpacity = opacity;
  switch (align) {
    case "left":
      textLine.leftAlignText();
      break;
    case "center":
      textLine.centerAlignText();
      break;
    case "right":
      textLine.rightAlignText();
      break;
    default:
      textLine.leftAlignText();
      break;
  }
}

function getImageUrl(name) {
  let fm = FileManager.iCloud();
  let dir = fm.documentsDirectory();
  return fm.joinPath(dir, `${name}`);
}

function setWidgetBackground(widget, imageName) {
  const imageUrl = getImageUrl(imageName);
  console.log(imageUrl);
  widget.backgroundImage = Image.fromFile(imageUrl);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment