Categories
가이드

아이폰 날씨+달력 투명 위젯 만들기 (ft. Scriptable)

Scriptable 은 자바스크립트를 이용한 위젯 만들기 앱입니다. iOS 14에서 본격적으로 위젯을 사용하기 시작했지만, 아직 자유도가 높다고 하긴 힘든데요. Scriptable은 위젯 네모 칸 안에 담은 코드를 바로 구현할 수 있기 때문에 필요한 코드만 구해서 조금 수정하면 내가 필요한 위젯을 고치고 만들 수 있게 됩니다.

목차

1. 왜 Scriptable이 매력적인가?

가령 달력을 위젯으로 표현하고 싶을 때 지금까지는 해당 달력 앱을 개발한 사람이 제공하는 디자인 중에서 골라야 했습니다. 하지만 이미 기기의 캘린더 일정을 끌어와서 표현해주는 일이라면 이제 Scriptable에서 누군가 만들어 놓은 위젯을 골라 조금 수정하면 원하는 위치에 원하는 디자인으로 일정을 표시할 수 있어요.

이밖에도 ~ 이미 아주 멋진 위젯들이 나왔습니다.

  • 자주 가는 블로그, 사이트의 최신 글이나 기사 목록 띄우기
  • 커다란 글자로 올해의 목표나 메모를 늘 보이게 걸어두기
  • 현재 위치의 날씨, 기온, 비 올 확률, 풍량, 주간 예보 등등을 그림, 그래프, 텍스트로 보이기
  • 임박한 캘린더 일정을 표시하기
  • 좋아하는 인스타그램 공개 계정의 최신 사진을 액자처럼 보이기
  • 공기 질 / 미세먼지 측정치 불러와서 표시하기
  • 특정 시점까지 남은 시간을 시각화하여 타이머로 표시
  • 특정 시점으로부터 경과한 시간을 그래프로 표시
  • 랜덤으로 플리커의 멋진 사진 표시
  • 랜덤으로 위키피디아 표제어 보기
  • 필요한 환율정보 긁어와서 보이기
  • 앱스토어 할인 앱 리스트 알려주기
  • 국내 또는 특정 국가의 코로나 확진자 정보 불러오기 ….

결국 사이트나 어떤 데이터 값을 퍼갈 수 있도록 제공하는 쪽에서 허락하고 있고 사용자가 그 정보를 코딩을 통해서 예쁘게 화면에 표시할 수 있는 능력만 있다면 이 앱의 가능성은 아주 높아요!

하지만 코딩을 할 줄 모른다고요? 걱정하지 마세요. 그냥 남들이 만든 좋은 코드를 받아 조금만 수정해서 쓸 수 있어도 충분합니다. 아래와 같은 화면을 아이폰에 심는 데 꼭 처음부터 모든 코드를 이해하고 쓸 수 있는 능력이 있어야 하는 건 아니에요.

저처럼 컴퓨터 프로그래밍을 거의 모르는 분을 위해, 제가 가지고 놀면서 위젯 하나 세팅한 과정을 정리해보았습니다. 한 번 따라해보시면 다른 위젯도 시도해보실 수 있을 겁니다.

1. Scriptable 앱 다운로드

먼저 앱스토어에서 Scriptable 앱을 다운로드합니다. 무료이며, 자발적인 기부를 원하시면 앱 안에서 개발자를 도와줄 수 있습니다.

2. Weather Cal Widget Builder 설치와 적용

2.1. 날씨 위젯 추가

갤러리 탭에 가면 상단에 Weather Cal Widget Builder를 추천하고 있는데 그걸로 시작해볼게요(GET). 소스 화면이 나타나면 “Add to My Scripts”를 눌러 가지고 옵니다.

1)코드를 실행해보면 2) 빨간 에러메시지가 뜹니다. 환경설정을 위해 일단 ‘Done’을 눌러 빠져나갑니다.

2.2. Openweathermap API 키 확보하기

날씨 정보를 불러오려면 누군가 제공한 데이터를 당겨와야 됩니다. OpenWeathermap.com 에 아이디가 없으신 분은 Sign-up 페이지로 가서 가입을 합니다. 아이디, 이메일, 패스워드, 패스워드확인, 16세이상체크, 약관동의 체크하시고, 각종 news는 체크 안 해도 됩니다. 그리고 “로봇이 아닙니다” 캡차 확인 후 “Create Account” 누릅니다. 다음 화면에서 왜 필요하냐고 묻는데 대충 Widget 만들 거라고 답해줍시다.

https://home.openweathermap.org/users/sign_up

가입할 때 적은 이메일로 확인 메일이 갑니다. Verify 버튼 눌러 인증해주세요. API keys 항목이 보입니다. 이후 API key를 복사해서 메모장에 잘 보관하세요.

이제 API key를 복사해서 아이폰 메모장에 잘 보관합니다.

2.3. 위젯에 날씨 API key 적용하기

다시 Scriptable 앱으로 돌아옵니다. 스크립트 목록에서 아까 받았던 “Weather Cal Widget Builder” 우측 상단의 ... 버튼을 눌러 편집 모드로 들어갑시다.

코드에서 //To use weather … 로 시작하는 설명 아래에 apiKey를 넣으라고 되어 있네요. 따옴표 사이에 아까 확보한 키를 넣어줍시다.

그리고 오른쪽 아래 재생버튼 ▶ 을 눌러 스크립트를 실행하면 위젯 미리보기가 나와요.

조금 다른 화면을 보고 계실 수도 있는데, 어쨌든 보이는 모양이 마음에 드시면 이대로 사용해도 됩니다.

3. Weather Cal Widget Builder 배경 바꾸기

일단 다른 것보다 위젯의 배경이 현재 폰 화면과 어울리지 않을 수 있습니다. forceImageUpadate = true 와 같이 값을 false 에서 true로 바꾸세요. 그리고 스크립트를 실행(▶)하면 배경 이미지 변경을 강제 시도하면서 사진첩에서 배경 사진을 고르라고 합니다.

하늘 사진으로 바꿔 보았습니다.

배경을 변경한 뒤에는 스크립트를 실행할 때 매번 배경 사진을 물어보지 않도록 forceImageUpdate = false 로 다시 바꾸고 “Done”을 눌러 빠져나오세요.

4. 위젯 배경 투명하게 만들기

이미 예쁜 배경사진을 사용하고 있다면, 위젯 배경을 투명하게 설정해보세요. 훨씬 보기 좋습니다. 1) 배경 위에 글자와 아이콘이 표시될테니까 좀 깔끔한 단색 계열의 배경 사진을 준비하고, 2) 위젯의 위치에 맞게 정확하게 배경을 잘라주는 작업이 필요합니다.

4.1. 스크린샷 확보하기

우선, 적당한 배경화면을 구해서 아이폰의 배경으로 새로 지정합니다. 저는 보라색 밤하늘이 있는 그림을 골랐습니다. 원본 사진을 아이폰 배경으로 설정하면서 대부분의 화면 영역을 그라디언트 하늘이 차지하도록 위치를 조정했습니다.

이제 아이폰에 적용된 상태의 깔끔한 배경 벽지를 오려내야 합니다. 아무 앱 아이콘을 오래 눌러 편집 모드로 바꾼 뒤에 화면을 오른쪽으로 계속 넘겨서 아무런 아이콘도 없는 빈 페이지를 스크린샷으로 저장합니다. 이제 위젯을 투명하게 보이는 것처럼 속이기 위한 배경 벽지가 마련되었습니다.

4.2. 위젯 배경 오려내기 위젯 받기

다음은 화면의 위젯 위치에 따라 정확히 위젯 모양으로 배경 사진을 오려내는 작업이 필요한데요. 생각보다 아이폰도 모델이 여러 개라서 제작자가 어디까지 세심하게 배려해주는지가 중요합니다.

어떤 분은 미세하게 칼질이 잘 안 된다고 하시던데, 저는 아이폰11프로 기준 아래 코드로 배경 잘라보니 잘 되는 거 같습니다. 출처는 Github mzeryck/Transparent-Scriptable-Widget입니다. 최신 코드는 해당 저장소를 방문해서 얻으시고, 아이폰에서 복사하기 힘들면 아래 붙여넣기해드릴테니 “copy” 버튼을 눌러 클립보드로 복사하세요.

// This widget was created by Max Zeryck @mzeryck

/*
You can't add commit messages to gists, so I just want to say thanks to everyone who has used, modified, 
and enjoyed this script. This version adds support for the iPhone 12 mini, thanks to arealhen for providing
a screenshot, and mintakka for a temporary solution.
*/

// Widgets are unique based on the name of the script.
const filename = Script.name() + ".jpg"
const files = FileManager.local()
const path = files.joinPath(files.documentsDirectory(), filename)

if (config.runsInWidget) {
  let widget = new ListWidget()
  widget.backgroundImage = files.readImage(path)
  
  // You can your own code here to add additional items to the "invisible" background of the widget.
  
  Script.setWidget(widget)
  Script.complete()

/*
 * The code below this comment is used to set up the invisible widget.
 * ===================================================================
 */
} else {
  
  // Determine if user has taken the screenshot.
  var message
  message = "Before you start, go to your home screen and enter wiggle mode. Scroll to the empty page on the far right and take a screenshot."
  let exitOptions = ["Continue","Exit to Take Screenshot"]
  let shouldExit = await generateAlert(message,exitOptions)
  if (shouldExit) return
  
  // Get screenshot and determine phone size.
  let img = await Photos.fromLibrary()
  let height = img.size.height
  let phone = phoneSizes()[height]
  if (!phone) {
    message = "It looks like you selected an image that isn't an iPhone screenshot, or your iPhone is not supported. Try again with a different image."
    await generateAlert(message,["OK"])
    return
  }
  
  // Extra setup needed for 2436-sized phones.
  if (height == 2436) {
  
    let cacheName = "mz-phone-type"
    let cachePath = files.joinPath(files.libraryDirectory(), cacheName)
  
    // If we already cached the phone size, load it.
    if (files.fileExists(cachePath)) {
      let typeString = files.readString(cachePath)
      phone = phone[typeString]
    
    // Otherwise, prompt the user.
    } else { 
      message = "What type of iPhone do you have?"
      let types = ["iPhone 12 mini", "iPhone 11 Pro, XS, or X"]
      let typeIndex = await generateAlert(message, types)
      let type = (typeIndex == 0) ? "mini" : "x"
      phone = phone[type]
      files.writeString(cachePath, type)
    }
  }
  
  // Prompt for widget size and position.
  message = "What size of widget are you creating?"
  let sizes = ["Small","Medium","Large"]
  let size = await generateAlert(message,sizes)
  let widgetSize = sizes[size]
  
  message = "What position will it be in?"
  message += (height == 1136 ? " (Note that your device only supports two rows of widgets, so the middle and bottom options are the same.)" : "")
  
  // Determine image crop based on phone size.
  let crop = { w: "", h: "", x: "", y: "" }
  if (widgetSize == "Small") {
    crop.w = phone.small
    crop.h = phone.small
    let positions = ["Top left","Top right","Middle left","Middle right","Bottom left","Bottom right"]
    let position = await generateAlert(message,positions)
    
    // Convert the two words into two keys for the phone size dictionary.
    let keys = positions[position].toLowerCase().split(' ')
    crop.y = phone[keys[0]]
    crop.x = phone[keys[1]]
    
  } else if (widgetSize == "Medium") {
    crop.w = phone.medium
    crop.h = phone.small
    
    // Medium and large widgets have a fixed x-value.
    crop.x = phone.left
    let positions = ["Top","Middle","Bottom"]
    let position = await generateAlert(message,positions)
    let key = positions[position].toLowerCase()
    crop.y = phone[key]
    
  } else if(widgetSize == "Large") {
    crop.w = phone.medium
    crop.h = phone.large
    crop.x = phone.left
    let positions = ["Top","Bottom"]
    let position = await generateAlert(message,positions)
    
    // Large widgets at the bottom have the "middle" y-value.
    crop.y = position ? phone.middle : phone.top
  }
  
  // Crop image and finalize the widget.
  let imgCrop = cropImage(img, new Rect(crop.x,crop.y,crop.w,crop.h))
  
  message = "Your widget background is ready. Would you like to use it as this script's background, or export the image for use in a different script or another widget app?"
  const exportPhotoOptions = ["Use for this script","Export to Photos","Export to Files"]
  const exportPhoto = await generateAlert(message,exportPhotoOptions)
  
  if (exportPhoto == 0) {
    files.writeImage(path,imgCrop)
  } else if (exportPhoto == 1) {
    Photos.save(imgCrop)
  } else if (exportPhoto == 2) {
    await DocumentPicker.exportImage(imgCrop)
  }
  
  Script.complete()
}

// Generate an alert with the provided array of options.
async function generateAlert(message,options) {
  
  let alert = new Alert()
  alert.message = message
  
  for (const option of options) {
    alert.addAction(option)
  }
  
  let response = await alert.presentAlert()
  return response
}

// Crop an image into the specified rect.
function cropImage(img,rect) {
   
  let draw = new DrawContext()
  draw.size = new Size(rect.width, rect.height)
  
  draw.drawImageAtPoint(img,new Point(-rect.x, -rect.y))  
  return draw.getImage()
}

// Pixel sizes and positions for widgets on all supported phones.
function phoneSizes() {
  let phones = {  
    
    // 12 Pro Max
    "2778": {
      small:  510,
      medium: 1092,
      large:  1146,
      left:  96,
      right: 678,
      top:    246,
      middle: 882,
      bottom: 1518
    },
  
    // 12 and 12 Pro
    "2532": {
      small:  474,
      medium: 1014,
      large:  1062,
      left:  78,
      right: 618,
      top:    231,
      middle: 819,
      bottom: 1407
    },
  
    // 11 Pro Max, XS Max
    "2688": {
      small:  507,
      medium: 1080,
      large:  1137,
      left:  81,
      right: 654,
      top:    228,
      middle: 858,
      bottom: 1488
    },
  
    // 11, XR
    "1792": {
      small:  338,
      medium: 720,
      large:  758,
      left:  54,
      right: 436,
      top:    160,
      middle: 580,
      bottom: 1000
    },
    
    
    // 11 Pro, XS, X, 12 mini
    "2436": {
     
      x: {
        small:  465,
        medium: 987,
        large:  1035,
        left:  69,
        right: 591,
        top:    213,
        middle: 783,
        bottom: 1353,
      },
      
      mini: {
        small:  465,
        medium: 987,
        large:  1035,
        left:  69,
        right: 591,
        top:    231,
        middle: 801,
        bottom: 1371,
      }
      
    },
  
    // Plus phones
    "2208": {
      small:  471,
      medium: 1044,
      large:  1071,
      left:  99,
      right: 672,
      top:    114,
      middle: 696,
      bottom: 1278
    },
    
    // SE2 and 6/6S/7/8
    "1334": {
      small:  296,
      medium: 642,
      large:  648,
      left:  54,
      right: 400,
      top:    60,
      middle: 412,
      bottom: 764
    },
    
    
    // SE1
    "1136": {
      small:  282,
      medium: 584,
      large:  622,
      left: 30,
      right: 332,
      top:  59,
      middle: 399,
      bottom: 399
    },
    
    // 11 and XR in Display Zoom mode
    "1624": {
      small: 310,
      medium: 658,
      large: 690,
      left: 46,
      right: 394,
      top: 142,
      middle: 522,
      bottom: 902 
    },
    
    // Plus in Display Zoom mode
    "2001" : {
      small: 444,
      medium: 963,
      large: 972,
      left: 81,
      right: 600,
      top: 90,
      middle: 618,
      bottom: 1146
    },
  }
  return phones
}

4.3. 투명배경 만들기 위젯 생성

이제 Scriptable 앱으로 이동해서 오른쪽 위 (+) 버튼을 눌러 새로운 위젯을 생성합니다. 그리고 아까 복사한 코드를 본문에 붙여넣기 하세요. 제목도 수정하시고요.

이제 오른쪽 아래 세모 재생 버튼을 눌러 스크립트를 실행합니다. … take a screenshot 어쩌구 하면서 스크린샷 찍으러 갈 거냐고 물어보면, 우리는 이미 스크린샷을 찍어뒀으니까 “Continue”를 눌러 다음 단계로 진행합니다.

이후 위젯 사이즈를 물어보면 “Large”를 선택하고, 위젯 위치(position)를 물어보면 “Top”을 선택합시다. 작업이 완료되면 “Export to Photos”를 눌러 오려낸 사진을 사진첩에 저장합니다. 그리고 “Done”을 눌러 빠져나옵니다.

4.4. 기존 위젯 배경에 맞춤 배경 적용

이제 다시 Weather Cal Widget Builder의 편집 모드로 갑시다. 그리고 배경을 교체해야겠죠? 교체 방법은 위 3번 설명을 참고하세요. 그럼 아래와 같이 위화감 없이 배경이 아름답게 맞춰 들어갑니다. 위젯 배경이 투명한 것처럼 보이지요?

이 상태에서 쓰셔도 됩니다.

5. 설정값 변경하기

앱 안에서 코드를 보면 변경하거나 새로 입력할 부분이 노란색 또는 보라색으로 표시되어 쉽게 눈에 보입니다.

5.1. 언어 설정과 아이콘 틴트

locale을 “ko”로 변경하면 “Friday” 대신 “금요일”처럼 한글로 표시됩니다. 현재 위치도 “Jung-gu” 대신 “중구”와 같이 표시되고요. 그냥 빈칸(“”)으로 두면 핸드폰의 언어 설정을 따라갑니다.

LockLocation을 “true”로 두면 위치가 고정되고 해당 위치의 날씨 정보를 가져옵니다. 계속 도시를 이동하거나 여행중이라면 “false”로 두세요. 위치를 고정하지 않고 핸드폰의 GPS 값에 따라 날씨를 업데이트합니다.

image background 를 false로 두면 이미지 배경 대신 그라디언트 배경을 볼 수 있고요. forceImageUpdate 는 이미 설명했으니 생략합니다.

const padding은 기본 여백 값이라고 생각하면 됩니다. 저는 2로 줄였습니다.

const tinticons 설정은 아이콘 색상 변경 여부입니다. false로 두면 원래 아이콘의 색상으로 표시되고, true로 두면 아이콘을 폰트 색상과 맞게 단색으로 표시합니다.


// Set the locale code. Leave blank "" to match the device's locale. You can change the hard-coded text strings in the TEXT section below.
let locale = "en"

// Set to true for fixed location, false to update location as you move around
const lockLocation = true

// The size of the widget preview in the app.
const widgetPreview = "large"

// Set to true for an image background, false for no image.
const imageBackground = true

// Set to true to reset the widget's background image.
const forceImageUpdate = false

// Set the padding around each item. Default is 5.
const padding = 2

// Decide if icons should match the color of the text around them.
const tintIcons = true

5.2. 인사말 수정

시간에 따라 인삿말이 표시됩니다. 대충 봐도 어디를 수정해야 하는지 알 수 있습니다. “좋은 아침입니다.” 등으로 한국어로 바꿀 수 있겠죠. 표시할 일정이 없을 때 “Enjoy the rest of your day”와 같은 축하 인사?를 건넬 수도 있습니다.

일정의 지속 기간을 표시할 때 1시간 일정이면 기본적으로 “1h”와 같이 표시하는데, 이 부분도 “1시간 30분”과 같이 한국어로 표시하고 싶으면 “m”과 “h”를 각각 고치면 됩니다.


// You can change the language or wording of any text in the widget.
const localizedText = {
  
  // The text shown if you add a greeting item to the layout.
  nightGreeting: "Good night."
  ,morningGreeting: "Good morning."
  ,afternoonGreeting: "Good afternoon."
  ,eveningGreeting: "Good evening."
  
  // The text shown if you add a future weather item to the layout, or tomorrow's events.
  ,nextHourLabel: "Next hour"
  ,tomorrowLabel: "Tomorrow"

  // Shown when noEventBehavior is set to "message".
  ,noEventMessage: "Enjoy the rest of your day."
  
  // The text shown after the hours and minutes of an event duration.
  ,durationMinute: "m"
  ,durationHour: "h"
     
}

6. 코드 수정하기

그대로 써도 좋을만큼 예쁜데, 몇 가지 마음에 안 들어서 고쳐보기로 합니다. 가령 레딧의 한 유저는 아래와 같이 미니멀하게 디자인을 바꿨어요.

minimal date weather events script by aaronfunk@Reddit

필요한 글자 크기는 키우고, 배경화면과 깔맞춤되도록 몇 가지 정보 표시 부분의 글자 색상을 변경하고, 레이아웃을 수정해 좌우 균형감을 잘 맞췄네요. 저 위의 코드는 이미 해당 사용자가 공개를 하였으니 그대로 가져다 쓰셔도 됩니다. 물론 배경 작업은 다시 해야겠죠?

저는 그래도 직접 한 번 만져보자 싶어서, 원래 코드에서 제가 이해가 되는 부분만 수정하면서 위 미니멀한 배열을 참고하면서 만져봤습니다. 저도 프로그래머가 아닙니다. 하지만 코드를 좀 고쳐보고 싶다면 참고해주시고, 혹시 제가 틀린 부분이 있다면 댓글로 알려주세요.

6.1. 레이아웃과 아이템 이해

우선 수정을 하고 싶으니 나란히 놓고.. 이 사람은 뭘 어떻게 바꾼 걸까 생각했어요.

  • 인삿말이 있네요? 시간에 따라 굿모닝~ 인사를 넣을 수 있나 봅니다.
  • 요일이나 날짜의 위치를 바꿀 수 있고, 크기도 키울 수 있군요.
  • 날씨 아이콘 위에 현재 위치 표시가 가능하군요. 또한 날씨 상태를 텍스트로 함께 표시할 수도 있고요.
  • 오늘의 최저-최고 기온 막대기를 없애니까 더 깔끔하네요.
  • 예보도 없앤 거 같네요. 예보는 보통 다음 한 시간 후의 예보가 나오다가 오후 8시가 되면 다음날 예보를 띄우도록 되어 있습니다.
  • 배터리 아이콘은 검은색이라 잘 안 보이는데 색상을 수정하고, 위치도 바꿨네요.

이제 수정해보겠습니다.

6.2. 아이템 레이아웃

우선 아이템 배열을 바꿔야 하는데, 일종의 표라고 생각하면 됩니다. 가로 행(row)와 세로열(column)으로 구성되어 있습니다.

원래 소스는 아래와 같습니다. 잘 보시면 가로로 한 줄(row)를 만들고, 그 안에 세로열(column)이 있고, 그 열에 날짜(date), 배터리(battery), 일출(sunrise), 빈 영역(space)를 순서대로 넣었네요. 그리고 그 오른쪽에 세로열 하나(column)이 추가되는데 가로폭을 90으로 정했습니다. 따라서 왼쪽 열은 자동 너비가 되겠지요. 오른쪽 90짜리 열에(column(90))… 현재날씨(current)와 날씨예보(future)를 넣었네요. 이제 가로 줄 하나가 끝나고, 다시 다음 가로줄(row)를 넣은 다음, 세로열(column)에 일정(events)을 넣었습니다. 말로 하니까 어려운데 그림이랑 비교해보면 감이 오실 거에요. 그리고 친절한 주석이 안에 있습니다. 모든 아이템에 컴마(,)를 빼먹지 마세요.

const items = [
  
  row,
     column,
     date,			 
     battery,
     sunrise,
     space,

     column(90),
     current,
     future,

   row,
     column,
     events,
  
]

우리가 원하는 건 아래와 같은 배열이지요? 시간별 인사는 ‘greeting’ 이름으로 된 아이템입니다.

수정된 코드는 아래와 같습니다. 우선 오른쪽 열을 78로 좁혔고요. 일정이 좀 더 아래에 붙도록 공간(space) 아이템을 삽입했습니다. 기타 예보(future)는 지웠고요.

const items = [
  
  row,
     column,
     greeting,			 
     date,

     column(78),
     current,
     battery,
     
   row,
     column,
     space,
     events,
  
]

저는 아래와 같은 설정으로 현재 쓰고 있어요. 왜냐하면 일정이 너무 많아서 오른쪽에 세로로 주르륵 많이 나오는 날이 많은데 다 보고 싶어서요. 이런 경우엔 가로 행은 하나에 인삿말 쓰고, 다시 가로행 하나 열어주고, 세로열 두개 틀을 만들면 되겠죠?

코드는 아래와 같을 겁니다.

const items = [
  
  row,
     column,
     greeting,			 

   row,
     column,
     date,
     current,
     battery,
     space,
  
     column(160),
     events,
  
]

6.3. 일정 정보 아이템 수정

날짜 표시 형식 변경

largeDateLineOne(첫 번째 줄)의 형식을 “d”로 변경해서 오늘 날짜만 표시하도록 하고, 두 번째 줄은 “eeee”로 변경하여 요일을 줄여쓰지 않고 모두 쓰도록 합니다. 매뉴얼에 표시 형식에 따른 약어가 잘 정리되어 있습니다.

// Determine the date format for each date type. See docs.scriptable.app/dateformatter
  ,smallDateFormat: "EEEE, MMMM d"
  ,largeDateLineOne: "d"
  ,largeDateLineTwo: "eeee"
}

표시할 일정의 최대값

원래 3개까지만 일정을 표시하도록 되어 있는데, 좁은 공간에 일정이 많이 표시되면 넘쳐흐르고 보기도 좋지 않아서 그런 거 같아요. 또 약속 시간이 있는 일정일 경우 두 줄로 표시되면서 공간이 부족해지기도 합니다. 저는 일정을 오른쪽으로 옮기면서 6개까지 맥시멈을 올렸어요. 이건 직접 만지면서 실용성과 화면 예쁘게 나오는 것 사이에서 수정하시기 바랍니다.

  // How many events to show.
  numberOfEvents: 6

일정의 지속시간 표시

원래 구글캘린더에 일정 입력할 때 몇 시 부터 ~ 몇 시까지 하면 지속 시간이 계산되지요. 가령 이게 한 시간짜리 회의냐.. 한 시간 반이냐.. 라는 건데.. 저는 이걸 지켜서 잘 입력하지 않기 때문에 비워두기(“”)로 했습니다. “duration”이라고 하면 “1h”처럼 한 시간 동안이라고 표시해줍니다.

  // Can be blank "" or set to "duration" or "time" to display how long an event is.
  ,showEventLength: ""

일정 데이터 끌어올 달력 선택

여러 캘린더를 사용하고 있어도 위젯에 표시할 캘린더를 지정할 수 있습니다. 저는 금전출납도 구글캘린더에 쓰는데, 이런 것까지 첫화면에 보고 싶진 않죠. 간단하게 캘린더 이름을 따옴표에 묶어 넣어주고, 컴마(,)로 여러 개 나열하면 됩니다.

// Set which calendars for which to show events. Empty [] means all calendars.
,selectCalendars: ['개인달력','회사일정']

일정 부분 누르면 이동할 주소 변경

원래 위젯에서 날짜 표시 부분을 탭하면 아이폰 기본 달력 앱을 띄우도록 되어 있습니다. 하지만 저는 아이폰 기본 달력은 구글 캘린더와 연결해두기만 하고, 실제 일정을 보거나 입력할 때 Readdle이 만든 Calendars5를 쓰고 있어요. 그래서 날짜를 누르면 제가 자주 쓰는 캘린더 앱을 띄우도록 수정했습니다. “calshow:’ 이하 부분을 ‘calendars://’로 변경합니다.

  // Set up the event stack.
  let eventStack = column.addStack()
  eventStack.layoutVertically()
  const todaySeconds = Math.floor(currentDate.getTime() / 1000) - 978307200
  eventStack.url = 'calendars://'

일정 스택의 여백 조정

현재 위와 같이 만들어 쓰고 있는데 오른쪽 일정이 시작되는 부분이 너무 위로 붙어 있는 거 같아서 조정이 필요했습니다. 안쪽 여백은 (상, 좌, 하, 우)의 순서일 거에요. 무조건 기본 위 여백 20은 먹고 들어가도록 했습니다.

 // If we're not showing the message, don't pad the event stack.
  eventStack.setPadding(20, 0, 0, 0)

내일 일정 부분 탭하면 이동할 주소

오늘 일정 외에 내일 일정도 표시되는데, 내일 일정 부분도 누르면 Calendars5 앱이 뜨도록 합니다. tomorrowStack url 을 수정하세요.

    // If it's the tomorrow label, change to the tomorrow stack.
    if (event.isLabel) {
      let tomorrowStack = column.addStack()
      tomorrowStack.layoutVertically()
      const tomorrowSeconds = Math.floor(currentDate.getTime() / 1000) - 978220800
      tomorrowStack.url = 'calendars://'
      currentStack = tomorrowStack

Tomorrow 제목 라벨 여백 수정

내일 일정이 시작되는 부분에 “TOMORROW” 글자가 있어 오늘과 내일을 구분합니다. 오늘과 내일 사이에 살짝 더 간격을 주고 싶었습니다. eventLabelStack.setPadding 값을 만져서 상단에 여백을 10 정도를 추가했네요.

      // Mimic the formatting of an event title, mostly.
      const eventLabelStack = align(currentStack)
      const eventLabel = provideText(event.title, eventLabelStack, textFormat.eventLabel)
      eventLabelStack.setPadding(10, 0, 0, padding)
      continue
    }

일정 아래 시간 표시 들여쓰기

각 일정에 정해진 시작 시간이 있는 경우 표시되는데, 살짝 들여쓰기를 하면 좋을 거 같았습니다. 그리고 각 일정에 표시되는 숫자는 해당 일정과 줄간격 없이 더 붙어 있으면 했네요. 그래서 왼쪽에 여백을 9 주고, 위로 -3을 줘서 위로 움직이도록 했습니다.

 timeStack.setPadding(-3, 9, padding, padding)

6.4. 날씨 정보 아이템 수정

섭씨 온도 표시

화씨로 표시되는 게 기본이라 수정해야겠죠? “Imperial”을 “metric”으로 고치세요.

  
// WEATHER
// =======
const weatherSettings = {

  // Set to imperial for Fahrenheit, or metric for Celsius
  units: "metric"

현재 날씨 상태 문자로 표시

현재 날씨 상태를 글자로 보고 싶으시면 showCondition 값을 true로 변경하세요. mist(연무), clear(맑음) 등으로 표시됩니다. 전 없는 게 더 이쁜 거 같습니다.

  // Show the text description of the current conditions.
  ,showCondition: true

오늘 최고/최저 기온 막대기

오늘 최저-최고 기온이 가로 막대기로 표시되는데, 싫으면 끄면 됩니다(false)

  // Show today's high and low temperatures.
  ,showHighLow: false

날씨 스택 여백

날씨 표시하는 그림이 너무 위로 붙은 거 같아서 살짝 아래로 여백 6을 줘서 내렸습니다. 또한 원래는 날씨 부분을 탭하면 weather.com으로 이동하도록 되어 있는데, 그다지 유용하지 않은 거 같아서 네이버 날씨로 이동하도록 했어요.

currentWeatherStack.setPadding(6, 0, 0, 0)
currentWeatherStack.url = "https://weather.naver.com/"

날씨 아이콘 크기 조정

날씨 아이콘이 좀 더 크게 표시되도록 수정했습니다. 너무 크게 하니까 해상도가 떨어져서 안 이쁘네요. 35 x 35 정도에서 타협합니다.

mainCondition.imageSize = new Size(35,35)

내일 날씨 예보 부분 터치하면 이동할 주소

역시 내일 예보를 표시하는 경우, 이곳을 터치해도 그냥 네이버 날씨로 가도록 했어요. 하지만 현재 저는 내일 예보는 꺼뒀습니다.

futureWeatherStack.url = "https://weather.naver.com/"

6.5 배터리, 일출일몰 표시 수정

배터리 이미지 사이즈를 더 작게 만들었습니다.

batteryIcon.imageSize = new Size(18,18)

또한 일출,일몰을 표시하는 아이콘 크기도 18 x 18 로 작게 수정했습니다. 현재 저는 사용하지 않지만요.

symbol.imageSize = new Size(18,18)

일출, 일몰 그림 부분을 터치했을 때 이동할 주소도 역시 네이버 날씨로 수정합니다.

sunriseStack.url = "https://weather.naver.com/"

6.6. 최저-최고 기온 막대기 수정

최저-최고 기온을 표시하는 막대와 현재 위치를 그 위의 점으로 표시하는 기능이 있습니다. 막대의 길이나 높이를 수정하게 되어 있습니다. 막대기 바로 위의 날씨 아이콘이 커졌는데 막대기는 너무 작으면 안 예쁩니다. 저는 200에서 250 정도로 키웠는데 나쁘지 않았습니다만 .. .현재는 표시 자체를 꺼 둔 상태입니다.

// Set the size of the temp bar.
  const tempBarWidth = 250
  const tempBarHeight = 20

6.7. 폰트와 색상 변경

아이템별로 폰트 종류와 색상을 지정할 수 있습니다. 기본 폰트(default)외에, eventTime처럼 일정시간 표시 부분의 폰트 크기와 색상도 조정할 수 있어요. 영문 폰트를 살짝 만지면 느낌이 확 달라집니다. 제 설정은 아래와 같고요. 레딧 보고 “AvenirNext” 폰트 예쁜 거 같아 적용했고, 색상이나 크기는 계속 보면서 조정했네요.

요령은 배경색에 들어간 색상을 응용해서 어울리도록 만져보라는 것. 자신 없으면 그냥 흰색(“ffffff”)으로 하거나 살짝 연한 색(“ddddd”) 정도를 추천합니다.


// Set the font, size, and color of various text elements. Use iosfonts.com to find fonts to use. If you want to use the default iOS font, set the font name to one of the following: ultralight, light, regular, medium, semibold, bold, heavy, black, or italic.
const textFormat = {
  
  // Set the default font and color.
  defaultText: { size: 12, color: "ffffff", font: "AvenirNext-Regular" },
  
  // Any blank values will use the default.
  smallDate:   { size: 24, color: "", font: "" },
  largeDate1:  { size: 50, color: "dddddd", font: "AvenirNext-DemiBold" },
  largeDate2:  { size: 25, color: "dddddd", font: "AvenirNext-Regular" },
  
  greeting:    { size: 20, color: "dddddd", font: "AvenirNext-Regular" },
  eventLabel:  { size: 14, color: "156d9f", font: "AvenirNext-Regular" },
  eventTitle:  { size: 14, color: "", font: "AvenirNext-Regular" },
  eventTime:   { size: 12, color: "ffffcc", font: "" },
  noEvents:    { size: 20, color: "dddddd", font: "" },
  
  largeTemp:   { size: 36, color: "cccccc", font: "AvenirNext-DemiBold" },
  smallTemp:   { size: 15, color: "dddddd", font: "" },
  tinyTemp:    { size: 15, color: "dddddd", font: "" },
  
  customText:  { size: 14, color: "", font: "" },
  
  battery:     { size: 13, color: "bbbbbb", font: "AvenirNext-Medium" },
  sunrise:     { size: 12, color: "dddddd", font: "" },
}

7. 최종 사용 코드 추천

현재 제가 사용하고 있는 코드입니다. const apiKey = "" 의 따옴표에 API 값 새로 넣어주세요. 달력 이름만 지정하면 바로 사용할 수 있습니다.

7.1. 상하 구분 버전

/*
 * SETUP
 * Use this section to set up the widget.
 * ======================================
 */

// To use weather, get a free API key at openweathermap.org/appid and paste it in between the quotation marks.
const apiKey = ""

// Set the locale code. Leave blank "" to match the device's locale. You can change the hard-coded text strings in the TEXT section below.
let locale = "en"

// Set to true for fixed location, false to update location as you move around
const lockLocation = true

// The size of the widget preview in the app.
const widgetPreview = "large"

// Set to true for an image background, false for no image.
const imageBackground = true

// Set to true to reset the widget's background image.
const forceImageUpdate = false

// Set the padding around each item. Default is 5.
const padding = 2

// Decide if icons should match the color of the text around them.
const tintIcons = true

/*
 * LAYOUT
 * Decide what items to show on the widget.
 * ========================================
 */

// You always need to start with "row," and "column," items, but you can now add as many as you want.
// Adding left, right, or center will align everything after that. The default alignment is left.

// You can add a flexible vertical space with "space," or a fixed-size space like this: "space(50)"
// Align items to the top or bottom of columns by adding "space," before or after all items in the column.

// There are many possible items, including: date, greeting, events, current, future, battery, sunrise, and text("Your text here")
// Make sure to always put a comma after each item.

const items = [
  
  row,
     column,
     greeting,			 
     date,

     column(78),
     current,
     battery,
     
   row,
     column,
     space,
     events,
  
]

/*
 * ITEM SETTINGS
 * Choose how each item is displayed.
 * ==================================
 */  
 
// DATE
// ====
const dateSettings = {

  // If set to true, date will become smaller when events are displayed.
  dynamicDateSize: false

  // If the date is not dynamic, should it be large or small?
  ,staticDateSize: "large"

  // Determine the date format for each date type. See docs.scriptable.app/dateformatter
  ,smallDateFormat: "EEEE, MMMM d"
  ,largeDateLineOne: "d"
  ,largeDateLineTwo: "eeee"
}

// EVENTS
// ======
const eventSettings = {

  // How many events to show.
  numberOfEvents: 6

  // Show all-day events.
  ,showAllDay: true

  // Show tomorrow's events.
  ,showTomorrow: true

  // Can be blank "" or set to "duration" or "time" to display how long an event is.
  ,showEventLength: ""

  // Set which calendars for which to show events. Empty [] means all calendars.
  ,selectCalendars: []

  // Leave blank "" for no color, or specify shape (circle, rectangle) and/or side (left, right).
  ,showCalendarColor: "rectangle left"
  
  // When no events remain, show a hard-coded "message", a "greeting", or "none".
  ,noEventBehavior: "message"
}

// SUNRISE
// =======
const sunriseSettings = {
  
  // How many minutes before/after sunrise or sunset to show this element. 0 for always.
  showWithin: 0
}

// WEATHER
// =======
const weatherSettings = {

  // Set to imperial for Fahrenheit, or metric for Celsius
  units: "metric"
  
  // Show the location of the current weather.
  ,showLocation: false
  
  // Show the text description of the current conditions.
  ,showCondition: true

  // Show today's high and low temperatures.
  ,showHighLow: false

  // Set the hour (in 24-hour time) to switch to tomorrow's weather. Set to 24 to never show it.
  ,tomorrowShownAtHour: 20
}

/*
 * TEXT
 * Change the language and formatting of text displayed.
 * =====================================================
 */  
 
// You can change the language or wording of any text in the widget.
const localizedText = {
  
  // The text shown if you add a greeting item to the layout.
  nightGreeting: "Good night."
  ,morningGreeting: "Good morning."
  ,afternoonGreeting: "Good afternoon."
  ,eveningGreeting: "Good evening."
  
  // The text shown if you add a future weather item to the layout, or tomorrow's events.
  ,nextHourLabel: "Next hour"
  ,tomorrowLabel: "Tomorrow"

  // Shown when noEventBehavior is set to "message".
  ,noEventMessage: "Enjoy the rest of your day."
  
  // The text shown after the hours and minutes of an event duration.
  ,durationMinute: "m"
  ,durationHour: "h"
     
}

// Set the font, size, and color of various text elements. Use iosfonts.com to find fonts to use. If you want to use the default iOS font, set the font name to one of the following: ultralight, light, regular, medium, semibold, bold, heavy, black, or italic.
const textFormat = {
  
  // Set the default font and color.
  defaultText: { size: 12, color: "ffffff", font: "AvenirNext-Regular" },
  
  // Any blank values will use the default.
  smallDate:   { size: 24, color: "", font: "" },
  largeDate1:  { size: 50, color: "dddddd", font: "AvenirNext-DemiBold" },
  largeDate2:  { size: 25, color: "dddddd", font: "AvenirNext-Regular" },
  
  greeting:    { size: 20, color: "dddddd", font: "AvenirNext-Regular" },
  eventLabel:  { size: 14, color: "156d9f", font: "AvenirNext-Regular" },
  eventTitle:  { size: 14, color: "", font: "AvenirNext-Regular" },
  eventTime:   { size: 12, color: "ffffcc", font: "" },
  noEvents:    { size: 20, color: "dddddd", font: "" },
  
  largeTemp:   { size: 34, color: "cccccc", font: "AvenirNext-DemiBold" },
  smallTemp:   { size: 15, color: "dddddd", font: "" },
  tinyTemp:    { size: 15, color: "dddddd", font: "" },
  
  customText:  { size: 14, color: "", font: "" },
  
  battery:     { size: 13, color: "bbbbbb", font: "AvenirNext-Medium" },
  sunrise:     { size: 12, color: "dddddd", font: "" },
}

/*
 * WIDGET CODE
 * Be more careful editing this section. 
 * =====================================
 */

// Make sure we have a locale value.
if (locale == "" || locale == null) { locale = Device.locale() }

// Declare the data variables.
var eventData, locationData, sunData, weatherData

// Create global constants.
const currentDate = new Date()
const files = FileManager.local()

/*
 * CONSTRUCTION
 * ============
 */

// Set up the widget with padding.
const widget = new ListWidget()
const horizontalPad = padding < 10 ? 10 - padding : 10
const verticalPad = padding < 15 ? 15 - padding : 15
widget.setPadding(horizontalPad, verticalPad, horizontalPad, verticalPad)
widget.spacing = 0

// Set up the global variables.
var currentRow = {}
var currentColumn = {}

// Set up the initial alignment.
var currentAlignment = alignLeft

// Set up the global ASCII variables.
var currentColumns = []
var rowNeedsSetup = false

// It's ASCII time!
if (typeof items[0] == 'string') {
  for (line of items[0].split(/\r?\n/)) { await processLine(line) }
}
// Otherwise, set up normally.
else {
  for (item of items) { await item(currentColumn) }
}

/*
 * BACKGROUND DISPLAY
 * ==================
 */

// If it's an image background, display it.
if (imageBackground) {
  
  // Determine if our image exists and when it was saved.
  const path = files.joinPath(files.documentsDirectory(), "weather-cal-image")
  const exists = files.fileExists(path)
  
  // If it exists and an update isn't forced, use the cache.
  if (exists && (config.runsInWidget || !forceImageUpdate)) {
    widget.backgroundImage = files.readImage(path)
  
  // If it's missing when running in the widget, use a gray background.
  } else if (!exists && config.runsInWidget) {
      widget.backgroundColor = Color.gray() 
    
  // But if we're running in app, prompt the user for the image.
  } else {
      const img = await Photos.fromLibrary()
      widget.backgroundImage = img
      files.writeImage(path, img)
  }
    
// If it's not an image background, show the gradient.
} else {
  let gradient = new LinearGradient()
  let gradientSettings = await setupGradient()
  
  gradient.colors = gradientSettings.color()
  gradient.locations = gradientSettings.position()
  
  widget.backgroundGradient = gradient
}

// Finish the widget and show a preview.
Script.setWidget(widget)
if (widgetPreview == "small") { widget.presentSmall() }
else if (widgetPreview == "medium") { widget.presentMedium() }
else if (widgetPreview == "large") { widget.presentLarge() }
Script.complete()

/*
 * ASCII FUNCTIONS
 * Now isn't this a lot of fun?
 * ============================
 */

// Provide the named function.
function provideFunction(name) {
  const functions = {
    space() { return space },
    left() { return left },
    right() { return right },
    center() { return center },
    date() { return date },
    greeting() { return greeting },
    events() { return events },
    current() { return current },
    future() { return future },
    battery() { return battery },
    sunrise() { return sunrise },
  }
  return functions[name]
}

// Processes a single line of ASCII. 
async function processLine(lineInput) {
  
  // Because iOS loves adding periods to everything.
  const line = lineInput.replace(/\.+/g,'')
  
  // If it's blank, return.
  if (line.trim() == '') { return }
  
  // If it's a line, enumerate previous columns (if any) and set up the new row.
  if (line[0] == '-' && line[line.length-1] == '-') { 
    if (currentColumns.length > 0) { await enumerateColumns() }
    rowNeedsSetup = true
    return
  }
  
  // If it's the first content row, finish the row setup.
  if (rowNeedsSetup) { 
    row(currentColumn)
    rowNeedsSetup = false 
  }
  
  // If there's a number, this is a setup row.
  const setupRow = line.match(/\d+/)

  // Otherwise, it has columns.
  const items = line.split('|')
  
  // Iterate through each item.
  for (var i=1; i < items.length-1; i++) {
    
    // If the current column doesn't exist, make it.
    if (!currentColumns[i]) { currentColumns[i] = { items: [] } }
    
    // Now we have a column to add the items to.
    const column = currentColumns[i].items
    
    // Get the current item and its trimmed version.
    const item = items[i]
    const trim = item.trim()
    
    // If it's not a function, figure out spacing.
    if (!provideFunction(trim)) { 
      
      // If it's a setup row, whether or not we find the number, we keep going.
      if (setupRow) {
        const value = parseInt(trim, 10)
        if (value) { currentColumns[i].width = value }
        continue
      }
      
      // If it's blank and we haven't already added a space, add one.
      const prevItem = column[column.length-1]
      if (trim == '' && (!prevItem || (prevItem && !prevItem.startsWith("space")))) {
        column.push("space")
      }
      
      // Either way, we're done.
      continue
    
    }
    
    // Determine the alignment.
    const index = item.indexOf(trim)
    const length = item.slice(index,item.length).length
    
    let align
    if (index > 0 && length > trim.length) { align = "center" }
    else if (index > 0) { align = "right" }
    else { align = "left" }
    
    // Add the items to the column.
    column.push(align)
    column.push(trim)
  }
}

// Runs the function names in each column.
async function enumerateColumns() {
  if (currentColumns.length > 0) {
    for (col of currentColumns) {
      
      // If it's null, go to the next one.
      if (!col) { continue }
      
      // If there's a width, use the width function.
      if (col.width) {
        column(col.width)(currentColumn)
        
      // Otherwise, create the column normally.
      } else {
        column(currentColumn)
      }
      for (item of col.items) {
        const func = provideFunction(item)()
        await func(currentColumn)
      }
    }
    currentColumns = []
  }
}

/*
 * LAYOUT FUNCTIONS
 * These functions manage spacing and alignment.
 * =============================================
 */

// Makes a new row on the widget.
function row(input = null) {

  function makeRow() {
    currentRow = widget.addStack()
    currentRow.layoutHorizontally()
    currentRow.setPadding(0, 0, 0, 0)
    currentColumn.spacing = 0
    
    // If input was given, make a column of that size.
    if (input > 0) { currentRow.size = new Size(0,input) }
  }
  
  // If there's no input or it's a number, it's being called in the layout declaration.
  if (!input || typeof input == "number") { return makeRow }
  
  // Otherwise, it's being called in the generator.
  else { makeRow() }
}

// Makes a new column on the widget.
function column(input = null) {
 
  function makeColumn() {
    currentColumn = currentRow.addStack()
    currentColumn.layoutVertically()
    currentColumn.setPadding(0, 0, 0, 0)
    currentColumn.spacing = 0
    
    // If input was given, make a column of that size.
    if (input > 0) { currentColumn.size = new Size(input,0) }
  }
  
  // If there's no input or it's a number, it's being called in the layout declaration.
  if (!input || typeof input == "number") { return makeColumn }
  
  // Otherwise, it's being called in the generator.
  else { makeColumn() }
}

// Create an aligned stack to add content to.
function align(column) {
  
  // Add the containing stack to the column.
  let alignmentStack = column.addStack()
  alignmentStack.layoutHorizontally()
  
  // Get the correct stack from the alignment function.
  let returnStack = currentAlignment(alignmentStack)
  returnStack.layoutVertically()
  return returnStack
}

// Create a right-aligned stack.
function alignRight(alignmentStack) {
  alignmentStack.addSpacer()
  let returnStack = alignmentStack.addStack()
  return returnStack
}

// Create a left-aligned stack.
function alignLeft(alignmentStack) {
  let returnStack = alignmentStack.addStack()
  alignmentStack.addSpacer()
  return returnStack
}

// Create a center-aligned stack.
function alignCenter(alignmentStack) {
  alignmentStack.addSpacer()
  let returnStack = alignmentStack.addStack()
  alignmentStack.addSpacer()
  return returnStack
}

// This function adds a space, with an optional amount.
function space(input = null) { 
  
  // This function adds a spacer with the input width.
  function spacer(column) {
  
    // If the input is null or zero, add a flexible spacer.
    if (!input || input == 0) { column.addSpacer() }
    
    // Otherwise, add a space with the specified length.
    else { column.addSpacer(input) }
  }
  
  // If there's no input or it's a number, it's being called in the column declaration.
  if (!input || typeof input == "number") { return spacer }
  
  // Otherwise, it's being called in the column generator.
  else { input.addSpacer() }
}

// Change the current alignment to right.
function right(x) { currentAlignment = alignRight }

// Change the current alignment to left.
function left(x) { currentAlignment = alignLeft }

// Change the current alignment to center.
function center(x) { currentAlignment = alignCenter }

/*
 * SETUP FUNCTIONS
 * These functions prepare data needed for items.
 * ==============================================
 */

// Set up the eventData object.
async function setupEvents() {
  
  eventData = {}
  const calendars = eventSettings.selectCalendars
  const numberOfEvents = eventSettings.numberOfEvents

  // Function to determine if an event should be shown.
  function shouldShowEvent(event) {
  
    // If events are filtered and the calendar isn't in the selected calendars, return false.
    if (calendars.length && !calendars.includes(event.calendar.title)) { return false }

    // Hack to remove canceled Office 365 events.
    if (event.title.startsWith("Canceled:")) { return false }

    // If it's an all-day event, only show if the setting is active.
    if (event.isAllDay) { return eventSettings.showAllDay }

    // Otherwise, return the event if it's in the future.
    return (event.startDate.getTime() > currentDate.getTime())
  }
  
  // Determine which events to show, and how many.
  const todayEvents = await CalendarEvent.today([])
  let shownEvents = 0
  let futureEvents = []
  
  for (const event of todayEvents) {
    if (shownEvents == numberOfEvents) { break }
    if (shouldShowEvent(event)) {
      futureEvents.push(event)
      shownEvents++
    }
  }

  // If there's room and we need to, show tomorrow's events.
  let multipleTomorrowEvents = false
  if (eventSettings.showTomorrow && shownEvents < numberOfEvents) {
  
    const tomorrowEvents = await CalendarEvent.tomorrow([])
    for (const event of tomorrowEvents) {
      if (shownEvents == numberOfEvents) { break }
      if (shouldShowEvent(event)) {
      
        // Add the tomorrow label prior to the first tomorrow event.
        if (!multipleTomorrowEvents) { 
          
          // The tomorrow label is pretending to be an event.
          futureEvents.push({ title: localizedText.tomorrowLabel.toUpperCase(), isLabel: true })
          multipleTomorrowEvents = true
        }
        
        // Show the tomorrow event and increment the counter.
        futureEvents.push(event)
        shownEvents++
      }
    }
  }
  
  // Store the future events, and whether or not any events are displayed.
  eventData.futureEvents = futureEvents
  eventData.eventsAreVisible = (futureEvents.length > 0) && (eventSettings.numberOfEvents > 0)
}

// Set up the gradient for the widget background.
async function setupGradient() {
  
  // Requirements: sunrise
  if (!sunData) { await setupSunrise() }

  let gradient = {
    dawn: {
      color() { return [new Color("142C52"), new Color("1B416F"), new Color("62668B")] },
      position() { return [0, 0.5, 1] },
    },

    sunrise: {
      color() { return [new Color("274875"), new Color("766f8d"), new Color("f0b35e")] },
      position() { return [0, 0.8, 1.5] },
    },

    midday: {
      color() { return [new Color("3a8cc1"), new Color("90c0df")] },
      position() { return [0, 1] },
    },

    noon: {
      color() { return [new Color("b2d0e1"), new Color("80B5DB"), new Color("3a8cc1")] },
      position() { return [-0.2, 0.2, 1.5] },
    },

    sunset: {
      color() { return [new Color("32327A"), new Color("662E55"), new Color("7C2F43")] },
      position() { return [0.1, 0.9, 1.2] },
    },

    twilight: {
      color() { return [new Color("021033"), new Color("16296b"), new Color("414791")] },
      position() { return [0, 0.5, 1] },
    },

    night: {
      color() { return [new Color("16296b"), new Color("021033"), new Color("021033"), new Color("113245")] },
      position() { return [-0.5, 0.2, 0.5, 1] },
    },
  }

  const sunrise = sunData.sunrise
  const sunset = sunData.sunset

  // Use sunrise or sunset if we're within 30min of it.
  if (closeTo(sunrise)<=15) { return gradient.sunrise }
  if (closeTo(sunset)<=15) { return gradient.sunset }

  // In the 30min before/after, use dawn/twilight.
  if (closeTo(sunrise)<=45 && currentDate.getTime() < sunrise) { return gradient.dawn }
  if (closeTo(sunset)<=45 && currentDate.getTime() > sunset) { return gradient.twilight }

  // Otherwise, if it's night, return night.
  if (isNight(currentDate)) { return gradient.night }

  // If it's around noon, the sun is high in the sky.
  if (currentDate.getHours() == 12) { return gradient.noon }

  // Otherwise, return the "typical" theme.
  return gradient.midday
}

// Set up the locationData object.
async function setupLocation() {

  locationData = {}
  const locationPath = files.joinPath(files.documentsDirectory(), "weather-cal-loc")

  // If our location is unlocked or cache doesn't exist, ask iOS for location.
  var readLocationFromFile = false
  if (!lockLocation || !files.fileExists(locationPath)) {
    try {
      const location = await Location.current()
      const geocode = await Location.reverseGeocode(location.latitude, location.longitude, locale)
      locationData.latitude = location.latitude
      locationData.longitude = location.longitude
      locationData.locality = geocode[0].locality
      files.writeString(locationPath, location.latitude + "|" + location.longitude + "|" + locationData.locality)
    
    } catch(e) {
      // If we fail in unlocked mode, read it from the cache.
      if (!lockLocation) { readLocationFromFile = true }
      
      // We can't recover if we fail on first run in locked mode.
      else { return }
    }
  }
  
  // If our location is locked or we need to read from file, do it.
  if (lockLocation || readLocationFromFile) {
    const locationStr = files.readString(locationPath).split("|")
    locationData.latitude = locationStr[0]
    locationData.longitude = locationStr[1]
    locationData.locality = locationStr[2]
  }
}

// Set up the sunData object.
async function setupSunrise() {

  // Requirements: location
  if (!locationData) { await setupLocation() }
  
  async function getSunData(date) {
    const req = "https://api.sunrise-sunset.org/json?lat=" + locationData.latitude + "&lng=" + locationData.longitude + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth()+1) + "-" + date.getDate()
    const data = await new Request(req).loadJSON()
    return data
  }

  // Set up the sunrise/sunset cache.
  const sunCachePath = files.joinPath(files.documentsDirectory(), "weather-cal-sunrise")
  const sunCacheExists = files.fileExists(sunCachePath)
  const sunCacheDate = sunCacheExists ? files.modificationDate(sunCachePath) : 0
  let sunDataRaw

  // If cache exists and was created today, use cached data.
  if (sunCacheExists && sameDay(currentDate, sunCacheDate)) {
    const sunCache = files.readString(sunCachePath)
    sunDataRaw = JSON.parse(sunCache)
  }
  
  // Otherwise, get the data from the server.
  else {

    sunDataRaw = await getSunData(currentDate)
  
    // Calculate tomorrow's date and get tomorrow's data.
    let tomorrowDate = new Date()
    tomorrowDate.setDate(currentDate.getDate() + 1)
    const tomorrowData = await getSunData(tomorrowDate)
    sunDataRaw.results.tomorrow = tomorrowData.results.sunrise
    
    // Cache the file.
    files.writeString(sunCachePath, JSON.stringify(sunDataRaw))
  }

  // Store the timing values.
  sunData = {}
  sunData.sunrise = new Date(sunDataRaw.results.sunrise).getTime()
  sunData.sunset = new Date(sunDataRaw.results.sunset).getTime()
  sunData.tomorrow = new Date(sunDataRaw.results.tomorrow).getTime()
}

// Set up the weatherData object.
async function setupWeather() {

  // Requirements: location
  if (!locationData) { await setupLocation() }

  // Set up the cache.
  const cachePath = files.joinPath(files.documentsDirectory(), "weather-cal-cache")
  const cacheExists = files.fileExists(cachePath)
  const cacheDate = cacheExists ? files.modificationDate(cachePath) : 0
  var weatherDataRaw

  // If cache exists and it's been less than 60 seconds since last request, use cached data.
  if (cacheExists && (currentDate.getTime() - cacheDate.getTime()) < 60000) {
    const cache = files.readString(cachePath)
    weatherDataRaw = JSON.parse(cache)

  // Otherwise, use the API to get new weather data.
  } else {
    const weatherReq = "https://api.openweathermap.org/data/2.5/onecall?lat=" + locationData.latitude + "&lon=" + locationData.longitude + "&exclude=minutely,alerts&units=" + weatherSettings.units + "&lang=" + locale + "&appid=" + apiKey
    weatherDataRaw = await new Request(weatherReq).loadJSON()
    files.writeString(cachePath, JSON.stringify(weatherDataRaw))
  }

  // Store the weather values.
  weatherData = {}
  weatherData.currentTemp = weatherDataRaw.current.temp
  weatherData.currentCondition = weatherDataRaw.current.weather[0].id
  weatherData.currentDescription = weatherDataRaw.current.weather[0].main
  weatherData.todayHigh = weatherDataRaw.daily[0].temp.max
  weatherData.todayLow = weatherDataRaw.daily[0].temp.min

  weatherData.nextHourTemp = weatherDataRaw.hourly[1].temp
  weatherData.nextHourCondition = weatherDataRaw.hourly[1].weather[0].id

  weatherData.tomorrowHigh = weatherDataRaw.daily[1].temp.max
  weatherData.tomorrowLow = weatherDataRaw.daily[1].temp.min
  weatherData.tomorrowCondition = weatherDataRaw.daily[1].weather[0].id
}

/*
 * WIDGET ITEMS
 * These functions display items on the widget.
 * ============================================
 */

// Display the date on the widget.
async function date(column) {

  // Requirements: events (if dynamicDateSize is enabled)
  if (!eventData && dateSettings.dynamicDateSize) { await setupEvents() }

  // Set up the date formatter and set its locale.
  let df = new DateFormatter()
  df.locale = locale
  
  // Show small if it's hard coded, or if it's dynamic and events are visible.
  if (dateSettings.staticDateSize == "small" || (dateSettings.dynamicDateSize && eventData.eventsAreVisible)) {
    let dateStack = align(column)
    dateStack.setPadding(padding, padding, padding, padding)

    df.dateFormat = dateSettings.smallDateFormat
    let dateText = provideText(df.string(currentDate), dateStack, textFormat.smallDate)
    
  // Otherwise, show the large date.
  } else {
    let dateOneStack = align(column)
    df.dateFormat = dateSettings.largeDateLineOne
    let dateOne = provideText(df.string(currentDate), dateOneStack, textFormat.largeDate1)
    dateOneStack.setPadding(padding/2, padding, 0, padding)
    
    let dateTwoStack = align(column)
    df.dateFormat = dateSettings.largeDateLineTwo
    let dateTwo = provideText(df.string(currentDate), dateTwoStack, textFormat.largeDate2)
    dateTwoStack.setPadding(0, padding, padding, padding)
  }
}

// Display a time-based greeting on the widget.
async function greeting(column) {

  // This function makes a greeting based on the time of day.
  function makeGreeting() {
    const hour = currentDate.getHours()
    if (hour    < 5)  { return localizedText.nightGreeting }
    if (hour    < 12) { return localizedText.morningGreeting }
    if (hour-12 < 5)  { return localizedText.afternoonGreeting }
    if (hour-12 < 10) { return localizedText.eveningGreeting }
    return localizedText.nightGreeting
  }
  
  // Set up the greeting.
  let greetingStack = align(column)
  let greeting = provideText(makeGreeting(), greetingStack, textFormat.greeting)
  greetingStack.setPadding(padding, padding, padding, padding)
}

// Display events on the widget.
async function events(column) {

  // Requirements: events
  if (!eventData) { await setupEvents() }

  // If no events are visible, figure out what to do.
  if (!eventData.eventsAreVisible) { 
    const display = eventSettings.noEventBehavior
    
    // If it's a greeting, let the greeting function handle it.
    if (display == "greeting") { return await greeting(column) }
    
    // If it's a message, get the localized text.
    if (display == "message" && localizedText.noEventMessage.length) {
      const messageStack = align(column)
      messageStack.setPadding(padding, padding, padding, padding)
      provideText(localizedText.noEventMessage, messageStack, textFormat.noEvents)
    }
    
    // Whether or not we displayed something, return here.
    return
  }
  
  // Set up the event stack.
  let eventStack = column.addStack()
  eventStack.layoutVertically()
  const todaySeconds = Math.floor(currentDate.getTime() / 1000) - 978307200
  eventStack.url = 'calendars://'
  
  // If there are no events and we have a message, show it and return.
  if (!eventData.eventsAreVisible && localizedText.noEventMessage.length) {
    let message = provideText(localizedText.noEventMessage, eventStack, textFormat.noEvents)
    eventStack.setPadding(padding, padding, padding, padding)
    return
  }
  
  // If we're not showing the message, don't pad the event stack.
  eventStack.setPadding(20, 0, 0, 0)
  
  // Add each event to the stack.
  var currentStack = eventStack
  const futureEvents = eventData.futureEvents
  for (let i = 0; i < futureEvents.length; i++) {
    
    const event = futureEvents[i]
    const bottomPadding = (padding-10 < 0) ? 0 : padding-10
    
    // If it's the tomorrow label, change to the tomorrow stack.
    if (event.isLabel) {
      let tomorrowStack = column.addStack()
      tomorrowStack.layoutVertically()
      const tomorrowSeconds = Math.floor(currentDate.getTime() / 1000) - 978220800
      tomorrowStack.url = 'calendars://'
      currentStack = tomorrowStack
      
      // Mimic the formatting of an event title, mostly.
      const eventLabelStack = align(currentStack)
      const eventLabel = provideText(event.title, eventLabelStack, textFormat.eventLabel)
      eventLabelStack.setPadding(10, 0, 0, padding)
      continue
    }
    
    const titleStack = align(currentStack)
    titleStack.layoutHorizontally()
    const showCalendarColor = eventSettings.showCalendarColor
    const colorShape = showCalendarColor.includes("circle") ? "circle" : "rectangle"
    
    // If we're showing a color, and it's not shown on the right, add it to the left.
    if (showCalendarColor.length && !showCalendarColor.includes("right")) {
      let colorItemText = provideTextSymbol(colorShape) + " "
      let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
      colorItem.textColor = event.calendar.color
    }

    const title = provideText(event.title.trim(), titleStack, textFormat.eventTitle)
    titleStack.setPadding(padding, padding, event.isAllDay ? padding : padding/5, padding)
    
    // If we're showing a color on the right, show it.
    if (showCalendarColor.length && showCalendarColor.includes("right")) {
      let colorItemText = " " + provideTextSymbol(colorShape)
      let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
      colorItem.textColor = event.calendar.color
    }
  
    // If there are too many events, limit the line height.
    if (futureEvents.length >= 3) { title.lineLimit = 1 }

    // If it's an all-day event, we don't need a time.
    if (event.isAllDay) { continue }
    
    // Format the time information.
    let timeText = formatTime(event.startDate)
    
    // If we show the length as time, add an en dash and the time.
    if (eventSettings.showEventLength == "time") { 
      timeText += "–" + formatTime(event.endDate) 
      
    // If we should it as a duration, add the minutes.
    } else if (eventSettings.showEventLength == "duration") {
      const duration = (event.endDate.getTime() - event.startDate.getTime()) / (1000*60)
      const hours = Math.floor(duration/60)
      const minutes = Math.floor(duration % 60)
      const hourText = hours>0 ? hours + localizedText.durationHour : ""
      const minuteText = minutes>0 ? minutes + localizedText.durationMinute : ""
      const showSpace = hourText.length && minuteText.length
      timeText += " \u2022 " + hourText + (showSpace ? " " : "") + minuteText
    }

    const timeStack = align(currentStack)
    const time = provideText(timeText, timeStack, textFormat.eventTime)
    timeStack.setPadding(-3, 9, padding, padding)
  }
}

// Display the current weather.
async function current(column) {

  // Requirements: weather and sunrise
  if (!weatherData) { await setupWeather() }
  if (!sunData) { await setupSunrise() }

  // Set up the current weather stack.
  let currentWeatherStack = column.addStack()
  currentWeatherStack.layoutVertically()
  currentWeatherStack.setPadding(6, 0, 0, 0)
  currentWeatherStack.url = "https://weather.naver.com/"
  
  // If we're showing the location, add it.
  if (weatherSettings.showLocation) {
    let locationTextStack = align(currentWeatherStack)
    let locationText = provideText(locationData.locality, locationTextStack, textFormat.smallTemp)
    locationTextStack.setPadding(padding, padding, padding, padding)
  }

  // Show the current condition symbol.
  let mainConditionStack = align(currentWeatherStack)
  let mainCondition = mainConditionStack.addImage(provideConditionSymbol(weatherData.currentCondition,isNight(currentDate)))
  mainCondition.imageSize = new Size(35,35)
  tintIcon(mainCondition, textFormat.largeTemp)
  mainConditionStack.setPadding(weatherSettings.showLocation ? 0 : padding, padding, 0, padding)
  
  // If we're showing the description, add it.
  if (weatherSettings.showCondition) {
    let conditionTextStack = align(currentWeatherStack)
    let conditionText = provideText(weatherData.currentDescription, conditionTextStack, textFormat.smallTemp)
    conditionTextStack.setPadding(padding, padding, 0, padding)
  }

  // Show the current temperature.
  const tempStack = align(currentWeatherStack)
  tempStack.setPadding(0, padding, 0, padding)
  const tempText = Math.round(weatherData.currentTemp) + "°"
  const temp = provideText(tempText, tempStack, textFormat.largeTemp)
  
  // If we're not showing the high and low, end it here.
  if (!weatherSettings.showHighLow) { return }

  // Show the temp bar and high/low values.
  let tempBarStack = align(currentWeatherStack)
  tempBarStack.layoutVertically()
  tempBarStack.setPadding(0, padding, padding, padding)
  
  let tempBar = drawTempBar()
  let tempBarImage = tempBarStack.addImage(tempBar)
  tempBarImage.size = new Size(50,0)
  
  tempBarStack.addSpacer(1)
  
  let highLowStack = tempBarStack.addStack()
  highLowStack.layoutHorizontally()
  
  const mainLowText = Math.round(weatherData.todayLow).toString()
  const mainLow = provideText(mainLowText, highLowStack, textFormat.tinyTemp)
  highLowStack.addSpacer()
  const mainHighText = Math.round(weatherData.todayHigh).toString()
  const mainHigh = provideText(mainHighText, highLowStack, textFormat.tinyTemp)
  
  tempBarStack.size = new Size(60,30)
}

// Display upcoming weather.
async function future(column) {

  // Requirements: weather and sunrise
  if (!weatherData) { await setupWeather() }
  if (!sunData) { await setupSunrise() }

  // Set up the future weather stack.
  let futureWeatherStack = column.addStack()
  futureWeatherStack.layoutVertically()
  futureWeatherStack.setPadding(0, 0, 0, 0)
  futureWeatherStack.url = "https://weather.naver.com/"

  // Determine if we should show the next hour.
  const showNextHour = (currentDate.getHours() < weatherSettings.tomorrowShownAtHour)
  
  // Set the label value.
  const subLabelStack = align(futureWeatherStack)
  const subLabelText = showNextHour ? localizedText.nextHourLabel : localizedText.tomorrowLabel
  const subLabel = provideText(subLabelText, subLabelStack, textFormat.smallTemp)
  subLabelStack.setPadding(0, padding, padding/2, padding)
  
  // Set up the sub condition stack.
  let subConditionStack = align(futureWeatherStack)
  subConditionStack.layoutHorizontally()
  subConditionStack.centerAlignContent()
  subConditionStack.setPadding(0, padding, padding, padding)
  
  // Determine if it will be night in the next hour.
  var nightCondition
  if (showNextHour) {
    const addHour = currentDate.getTime() + (60*60*1000)
    const newDate = new Date(addHour)
    nightCondition = isNight(newDate)
  } else {
    nightCondition = false 
  }
  
  let subCondition = subConditionStack.addImage(provideConditionSymbol(showNextHour ? weatherData.nextHourCondition : weatherData.tomorrowCondition,nightCondition))
  const subConditionSize = showNextHour ? 14 : 18
  subCondition.imageSize = new Size(subConditionSize, subConditionSize)
  tintIcon(subCondition, textFormat.smallTemp)
  subConditionStack.addSpacer(5)
  
  // The next part of the display changes significantly for next hour vs tomorrow.
  if (showNextHour) {
    const subTempText = Math.round(weatherData.nextHourTemp) + "°"
    const subTemp = provideText(subTempText, subConditionStack, textFormat.smallTemp)
    
  } else {
    let tomorrowLine = subConditionStack.addImage(drawVerticalLine(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5), 20))
    tomorrowLine.imageSize = new Size(3,28)
    subConditionStack.addSpacer(5)
    let tomorrowStack = subConditionStack.addStack()
    tomorrowStack.layoutVertically()
    
    const tomorrowHighText = Math.round(weatherData.tomorrowHigh) + ""
    const tomorrowHigh = provideText(tomorrowHighText, tomorrowStack, textFormat.tinyTemp)
    tomorrowStack.addSpacer(4)
    const tomorrowLowText = Math.round(weatherData.tomorrowLow) + ""
    const tomorrowLow = provideText(tomorrowLowText, tomorrowStack, textFormat.tinyTemp)
  }
}

// Return a text-creation function.
function text(input = null) {

  function displayText(column) {
  
    // Don't do anything if the input is blank.
    if (!input || input == "") { return }
  
    // Otherwise, add the text.
    const textStack = align(column)
    textStack.setPadding(padding, padding, padding, padding)
    const textDisplay = provideText(input, textStack, textFormat.customText)
  }
  return displayText
}

// Add a battery element to the widget; consisting of a battery icon and percentage.
async function battery(column) {

  // Get battery level via Scriptable function and format it in a convenient way
  function getBatteryLevel() {
  
    const batteryLevel = Device.batteryLevel()
    const batteryPercentage = `${Math.round(batteryLevel * 100)}%`

    return batteryPercentage
  }
  
  const batteryLevel = Device.batteryLevel()
  
  // Set up the battery level item
  let batteryStack = align(column)
  batteryStack.layoutHorizontally()
  batteryStack.centerAlignContent()

  let batteryIcon = batteryStack.addImage(provideBatteryIcon())
  batteryIcon.imageSize = new Size(18,18)
  
  // Change the battery icon to red if battery level is <= 20 to match system behavior
  if ( Math.round(batteryLevel * 100) > 20 || Device.isCharging() ) {

    tintIcon(batteryIcon, textFormat.battery)

  } else {

    batteryIcon.tintColor = Color.red()

  }

  batteryStack.addSpacer(padding * 0.6)

  // Display the battery status
  let batteryInfo = provideText(getBatteryLevel(), batteryStack, textFormat.battery)

  batteryStack.setPadding(padding/2, padding, padding/2, padding)

}

// Show the sunrise or sunset time.
async function sunrise(column) {
  
  // Requirements: sunrise
  if (!sunData) { await setupSunrise() }
  
  const sunrise = sunData.sunrise
  const sunset = sunData.sunset
  const tomorrow = sunData.tomorrow
  const current = currentDate.getTime()
  
  const showWithin = sunriseSettings.showWithin
  const closeToSunrise = closeTo(sunrise) <= showWithin
  const closeToSunset = closeTo(sunset) <= showWithin

  // If we only show sometimes and we're not close, return.
  if (showWithin > 0 && !closeToSunrise && !closeToSunset) { return }
  
  // Otherwise, determine which time to show.
  let timeToShow, symbolName
  const halfHour = 30 * 60 * 1000
  
  // If we're between sunrise and sunset, show the sunset.
  if (current > sunrise + halfHour && current < sunset + halfHour) {
    symbolName = "sunset.fill"
    timeToShow = sunset
  }
  
  // Otherwise, show a sunrise.
  else {
    symbolName = "sunrise.fill"
    timeToShow = current > sunset ? tomorrow : sunrise
  }
  
  // Set up the stack.
  const sunriseStack = align(column)
  sunriseStack.setPadding(padding/2, padding, padding/2, padding)
  sunriseStack.layoutHorizontally()
  sunriseStack.centerAlignContent()
  
  sunriseStack.addSpacer(padding * 0.3)
  
  // Add the correct symbol.
  const symbol = sunriseStack.addImage(SFSymbol.named(symbolName).image)
  symbol.imageSize = new Size(18,18)
  tintIcon(symbol, textFormat.sunrise)
  
  sunriseStack.addSpacer(padding)
  sunriseStack.url = "https://weather.naver.com/"
  
  // Add the time.
  const timeText = formatTime(new Date(timeToShow))
  const time = provideText(timeText, sunriseStack, textFormat.sunrise)
}

// Allow for either term to be used.
async function sunset(column) {
  return await sunrise(column)
}


/*
 * HELPER FUNCTIONS
 * These functions perform duties for other functions.
 * ===================================================
 */

// Tints icons if needed.
function tintIcon(icon,format) {
  if (!tintIcons) { return }
  icon.tintColor = new Color(format.color || textFormat.defaultText.color)
}

// Determines if the provided date is at night.
function isNight(dateInput) {
  const timeValue = dateInput.getTime()
  return (timeValue < sunData.sunrise) || (timeValue > sunData.sunset)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}

// Returns the number of minutes between now and the provided date.
function closeTo(time) {
  return Math.abs(currentDate.getTime() - time) / 60000
}

// Format the time for a Date input.
function formatTime(date) {
  let df = new DateFormatter()
  df.locale = locale
  df.useNoDateStyle()
  df.useShortTimeStyle()
  return df.string(date)
}

// Provide a text symbol with the specified shape.
function provideTextSymbol(shape) {

  // Rectangle character.
  if (shape.startsWith("rect")) {
    return "\u2759"
  }
  // Circle character.
  if (shape == "circle") {
    return "\u2B24"
  }
  // Default to the rectangle.
  return "\u2759" 
}

// Provide a battery SFSymbol with accurate level drawn on top of it.
function provideBatteryIcon() {
  
  // If we're charging, show the charging icon.
  if (Device.isCharging()) { return SFSymbol.named("battery.100.bolt").image }
  
  // Set the size of the battery icon.
  const batteryWidth = 87
  const batteryHeight = 41
  
  // Start our draw context.
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(batteryWidth, batteryHeight)
  
  // Draw the battery.
  draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
  
  // Match the battery level values to the SFSymbol.
  const x = batteryWidth*0.1525
  const y = batteryHeight*0.247
  const width = batteryWidth*0.602
  const height = batteryHeight*0.505
  
  // Prevent unreadable icons.
  let level = Device.batteryLevel()
  if (level < 0.05) { level = 0.05 }
  
  // Determine the width and radius of the battery level.
  const current = width * level
  let radius = height/6.5
  
  // When it gets low, adjust the radius to match.
  if (current < (radius * 2)) { radius = current / 2 }

  // Make the path for the battery level.
  let barPath = new Path()
  barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
  draw.addPath(barPath)
  const color = tintIcons ? (textFormat.battery.color || textFormat.defaultText.color) : "000000"
  draw.setFillColor(new Color(color))
  draw.fillPath()
  return draw.getImage()
}

// Provide a symbol based on the condition.
function provideConditionSymbol(cond,night) {
  
  // Define our symbol equivalencies.
  let symbols = {
  
    // Thunderstorm
    "2": function() { return "cloud.bolt.rain.fill" },
    
    // Drizzle
    "3": function() { return "cloud.drizzle.fill" },
    
    // Rain
    "5": function() { return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
    
    // Snow
    "6": function() { return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
    
    // Atmosphere
    "7": function() {
      if (cond == 781) { return "tornado" }
      if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
      return night ? "cloud.fog.fill" : "sun.haze.fill"
    },
    
    // Clear and clouds
    "8": function() {
      if (cond == 800 || cond == 801) { return night ? "moon.stars.fill" : "sun.max.fill" }
      if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" }
      return "cloud.fill"
    }
  }
  
  // Find out the first digit.
  let conditionDigit = Math.floor(cond / 100)
  
  // Get the symbol.
  return SFSymbol.named(symbols[conditionDigit]()).image
}

// Provide a font based on the input.
function provideFont(fontName, fontSize) {
  const fontGenerator = {
    "ultralight": function() { return Font.ultraLightSystemFont(fontSize) },
    "light": function() { return Font.lightSystemFont(fontSize) },
    "regular": function() { return Font.regularSystemFont(fontSize) },
    "medium": function() { return Font.mediumSystemFont(fontSize) },
    "semibold": function() { return Font.semiboldSystemFont(fontSize) },
    "bold": function() { return Font.boldSystemFont(fontSize) },
    "heavy": function() { return Font.heavySystemFont(fontSize) },
    "black": function() { return Font.blackSystemFont(fontSize) },
    "italic": function() { return Font.italicSystemFont(fontSize) }
  }
  
  const systemFont = fontGenerator[fontName]
  if (systemFont) { return systemFont() }
  return new Font(fontName, fontSize)
}
 
// Add formatted text to a container.
function provideText(string, container, format) {
  const textItem = container.addText(string)
  const textFont = format.font || textFormat.defaultText.font
  const textSize = format.size || textFormat.defaultText.size
  const textColor = format.color || textFormat.defaultText.color
  
  textItem.font = provideFont(textFont, textSize)
  textItem.textColor = new Color(textColor)
  return textItem
}

/*
 * DRAWING FUNCTIONS
 * These functions draw onto a canvas.
 * ===================================
 */

// Draw the vertical line in the tomorrow view.
function drawVerticalLine(color, height) {
  
  const width = 2
  
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(width,height)
  
  let barPath = new Path()
  const barHeight = height
  barPath.addRoundedRect(new Rect(0, 0, width, height), width/2, width/2)
  draw.addPath(barPath)
  draw.setFillColor(color)
  draw.fillPath()
  
  return draw.getImage()
}

// Draw the temp bar.
function drawTempBar() {

  // Set the size of the temp bar.
  const tempBarWidth = 250
  const tempBarHeight = 20
  
  // Calculate the current percentage of the high-low range.
  let percent = (weatherData.currentTemp - weatherData.todayLow) / (weatherData.todayHigh - weatherData.todayLow)

  // If we're out of bounds, clip it.
  if (percent < 0) {
    percent = 0
  } else if (percent > 1) {
    percent = 1
  }

  // Determine the scaled x-value for the current temp.
  const currPosition = (tempBarWidth - tempBarHeight) * percent

  // Start our draw context.
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(tempBarWidth, tempBarHeight)

  // Make the path for the bar.
  let barPath = new Path()
  const barHeight = tempBarHeight - 10
  barPath.addRoundedRect(new Rect(0, 5, tempBarWidth, barHeight), barHeight / 2, barHeight / 2)
  draw.addPath(barPath)
  
  // Determine the color.
  const barColor = textFormat.battery.color || textFormat.defaultText.color
  draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5))
  draw.fillPath()

  // Make the path for the current temp indicator.
  let currPath = new Path()
  currPath.addEllipse(new Rect(currPosition, 0, tempBarHeight, tempBarHeight))
  draw.addPath(currPath)
  draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 1))
  draw.fillPath()

  return draw.getImage()
}

7.2. 좌우 구분 버전 (많은 일정 표시)

/*
 * SETUP
 * Use this section to set up the widget.
 * ======================================
 */

// To use weather, get a free API key at openweathermap.org/appid and paste it in between the quotation marks.
const apiKey = ""

// Set the locale code. Leave blank "" to match the device's locale. You can change the hard-coded text strings in the TEXT section below.
let locale = "en"

// Set to true for fixed location, false to update location as you move around
const lockLocation = true

// The size of the widget preview in the app.
const widgetPreview = "large"

// Set to true for an image background, false for no image.
const imageBackground = true

// Set to true to reset the widget's background image.
const forceImageUpdate = false

// Set the padding around each item. Default is 5.
const padding = 2

// Decide if icons should match the color of the text around them.
const tintIcons = true

/*
 * LAYOUT
 * Decide what items to show on the widget.
 * ========================================
 */

// You always need to start with "row," and "column," items, but you can now add as many as you want.
// Adding left, right, or center will align everything after that. The default alignment is left.

// You can add a flexible vertical space with "space," or a fixed-size space like this: "space(50)"
// Align items to the top or bottom of columns by adding "space," before or after all items in the column.

// There are many possible items, including: date, greeting, events, current, future, battery, sunrise, and text("Your text here")
// Make sure to always put a comma after each item.

const items = [
  
  row,
  
     column,
     greeting,
  
  row,

     column,
     date,
     current,
     battery,
			 
     space,

				
			 
			

	   

     column(160),
     events,
  
]

/*
 * ITEM SETTINGS
 * Choose how each item is displayed.
 * ==================================
 */  
 
// DATE
// ====
const dateSettings = {

  // If set to true, date will become smaller when events are displayed.
  dynamicDateSize: false

  // If the date is not dynamic, should it be large or small?
  ,staticDateSize: "large"

  // Determine the date format for each date type. See docs.scriptable.app/dateformatter
  ,smallDateFormat: "EEEE, MMMM d"
  ,largeDateLineOne: "d"
  ,largeDateLineTwo: "eeee"
}

// EVENTS
// ======
const eventSettings = {

  // How many events to show.
  numberOfEvents: 6

  // Show all-day events.
  ,showAllDay: true

  // Show tomorrow's events.
  ,showTomorrow: true

  // Can be blank "" or set to "duration" or "time" to display how long an event is.
  ,showEventLength: ""

  // Set which calendars for which to show events. Empty [] means all calendars.
  ,selectCalendars: []

  // Leave blank "" for no color, or specify shape (circle, rectangle) and/or side (left, right).
  ,showCalendarColor: "rectangle left"
  
  // When no events remain, show a hard-coded "message", a "greeting", or "none".
  ,noEventBehavior: "message"
}

// SUNRISE
// =======
const sunriseSettings = {
  
  // How many minutes before/after sunrise or sunset to show this element. 0 for always.
  showWithin: 0
}

// WEATHER
// =======
const weatherSettings = {

  // Set to imperial for Fahrenheit, or metric for Celsius
  units: "metric"
  
  // Show the location of the current weather.
  ,showLocation: false
  
  // Show the text description of the current conditions.
  ,showCondition: true

  // Show today's high and low temperatures.
  ,showHighLow: false

  // Set the hour (in 24-hour time) to switch to tomorrow's weather. Set to 24 to never show it.
  ,tomorrowShownAtHour: 20
}

/*
 * TEXT
 * Change the language and formatting of text displayed.
 * =====================================================
 */  
 
// You can change the language or wording of any text in the widget.
const localizedText = {
  
  // The text shown if you add a greeting item to the layout.
  nightGreeting: "Good night."
  ,morningGreeting: "Good morning."
  ,afternoonGreeting: "Good afternoon."
  ,eveningGreeting: "Good evening."
  
  // The text shown if you add a future weather item to the layout, or tomorrow's events.
  ,nextHourLabel: "Next hour"
  ,tomorrowLabel: "Tomorrow"

  // Shown when noEventBehavior is set to "message".
  ,noEventMessage: "Enjoy the rest of your day."
  
  // The text shown after the hours and minutes of an event duration.
  ,durationMinute: "m"
  ,durationHour: "h"
     
}

// Set the font, size, and color of various text elements. Use iosfonts.com to find fonts to use. If you want to use the default iOS font, set the font name to one of the following: ultralight, light, regular, medium, semibold, bold, heavy, black, or italic.
const textFormat = {
  
  // Set the default font and color.
  defaultText: { size: 12, color: "ffffff", font: "AvenirNext-Regular" },
  
  // Any blank values will use the default.
  smallDate:   { size: 24, color: "", font: "" },
  largeDate1:  { size: 50, color: "dddddd", font: "AvenirNext-DemiBold" },
  largeDate2:  { size: 25, color: "dddddd", font: "AvenirNext-Regular" },
  
  greeting:    { size: 20, color: "dddddd", font: "AvenirNext-Regular" },
  eventLabel:  { size: 14, color: "156d9f", font: "AvenirNext-Regular" },
  eventTitle:  { size: 14, color: "", font: "AvenirNext-Regular" },
  eventTime:   { size: 12, color: "ffffcc", font: "" },
  noEvents:    { size: 20, color: "dddddd", font: "" },
  
  largeTemp:   { size: 34, color: "cccccc", font: "AvenirNext-DemiBold" },
  smallTemp:   { size: 15, color: "dddddd", font: "" },
  tinyTemp:    { size: 15, color: "dddddd", font: "" },
  
  customText:  { size: 14, color: "", font: "" },
  
  battery:     { size: 13, color: "bbbbbb", font: "AvenirNext-Medium" },
  sunrise:     { size: 12, color: "dddddd", font: "" },
}

/*
 * WIDGET CODE
 * Be more careful editing this section. 
 * =====================================
 */

// Make sure we have a locale value.
if (locale == "" || locale == null) { locale = Device.locale() }

// Declare the data variables.
var eventData, locationData, sunData, weatherData

// Create global constants.
const currentDate = new Date()
const files = FileManager.local()

/*
 * CONSTRUCTION
 * ============
 */

// Set up the widget with padding.
const widget = new ListWidget()
const horizontalPad = padding < 10 ? 10 - padding : 10
const verticalPad = padding < 15 ? 15 - padding : 15
widget.setPadding(horizontalPad, verticalPad, horizontalPad, verticalPad)
widget.spacing = 0

// Set up the global variables.
var currentRow = {}
var currentColumn = {}

// Set up the initial alignment.
var currentAlignment = alignLeft

// Set up the global ASCII variables.
var currentColumns = []
var rowNeedsSetup = false

// It's ASCII time!
if (typeof items[0] == 'string') {
  for (line of items[0].split(/\r?\n/)) { await processLine(line) }
}
// Otherwise, set up normally.
else {
  for (item of items) { await item(currentColumn) }
}

/*
 * BACKGROUND DISPLAY
 * ==================
 */

// If it's an image background, display it.
if (imageBackground) {
  
  // Determine if our image exists and when it was saved.
  const path = files.joinPath(files.documentsDirectory(), "weather-cal-image")
  const exists = files.fileExists(path)
  
  // If it exists and an update isn't forced, use the cache.
  if (exists && (config.runsInWidget || !forceImageUpdate)) {
    widget.backgroundImage = files.readImage(path)
  
  // If it's missing when running in the widget, use a gray background.
  } else if (!exists && config.runsInWidget) {
      widget.backgroundColor = Color.gray() 
    
  // But if we're running in app, prompt the user for the image.
  } else {
      const img = await Photos.fromLibrary()
      widget.backgroundImage = img
      files.writeImage(path, img)
  }
    
// If it's not an image background, show the gradient.
} else {
  let gradient = new LinearGradient()
  let gradientSettings = await setupGradient()
  
  gradient.colors = gradientSettings.color()
  gradient.locations = gradientSettings.position()
  
  widget.backgroundGradient = gradient
}

// Finish the widget and show a preview.
Script.setWidget(widget)
if (widgetPreview == "small") { widget.presentSmall() }
else if (widgetPreview == "medium") { widget.presentMedium() }
else if (widgetPreview == "large") { widget.presentLarge() }
Script.complete()

/*
 * ASCII FUNCTIONS
 * Now isn't this a lot of fun?
 * ============================
 */

// Provide the named function.
function provideFunction(name) {
  const functions = {
    space() { return space },
    left() { return left },
    right() { return right },
    center() { return center },
    date() { return date },
    greeting() { return greeting },
    events() { return events },
    current() { return current },
    future() { return future },
    battery() { return battery },
    sunrise() { return sunrise },
  }
  return functions[name]
}

// Processes a single line of ASCII. 
async function processLine(lineInput) {
  
  // Because iOS loves adding periods to everything.
  const line = lineInput.replace(/\.+/g,'')
  
  // If it's blank, return.
  if (line.trim() == '') { return }
  
  // If it's a line, enumerate previous columns (if any) and set up the new row.
  if (line[0] == '-' && line[line.length-1] == '-') { 
    if (currentColumns.length > 0) { await enumerateColumns() }
    rowNeedsSetup = true
    return
  }
  
  // If it's the first content row, finish the row setup.
  if (rowNeedsSetup) { 
    row(currentColumn)
    rowNeedsSetup = false 
  }
  
  // If there's a number, this is a setup row.
  const setupRow = line.match(/\d+/)

  // Otherwise, it has columns.
  const items = line.split('|')
  
  // Iterate through each item.
  for (var i=1; i < items.length-1; i++) {
    
    // If the current column doesn't exist, make it.
    if (!currentColumns[i]) { currentColumns[i] = { items: [] } }
    
    // Now we have a column to add the items to.
    const column = currentColumns[i].items
    
    // Get the current item and its trimmed version.
    const item = items[i]
    const trim = item.trim()
    
    // If it's not a function, figure out spacing.
    if (!provideFunction(trim)) { 
      
      // If it's a setup row, whether or not we find the number, we keep going.
      if (setupRow) {
        const value = parseInt(trim, 10)
        if (value) { currentColumns[i].width = value }
        continue
      }
      
      // If it's blank and we haven't already added a space, add one.
      const prevItem = column[column.length-1]
      if (trim == '' && (!prevItem || (prevItem && !prevItem.startsWith("space")))) {
        column.push("space")
      }
      
      // Either way, we're done.
      continue
    
    }
    
    // Determine the alignment.
    const index = item.indexOf(trim)
    const length = item.slice(index,item.length).length
    
    let align
    if (index > 0 && length > trim.length) { align = "center" }
    else if (index > 0) { align = "right" }
    else { align = "left" }
    
    // Add the items to the column.
    column.push(align)
    column.push(trim)
  }
}

// Runs the function names in each column.
async function enumerateColumns() {
  if (currentColumns.length > 0) {
    for (col of currentColumns) {
      
      // If it's null, go to the next one.
      if (!col) { continue }
      
      // If there's a width, use the width function.
      if (col.width) {
        column(col.width)(currentColumn)
        
      // Otherwise, create the column normally.
      } else {
        column(currentColumn)
      }
      for (item of col.items) {
        const func = provideFunction(item)()
        await func(currentColumn)
      }
    }
    currentColumns = []
  }
}

/*
 * LAYOUT FUNCTIONS
 * These functions manage spacing and alignment.
 * =============================================
 */

// Makes a new row on the widget.
function row(input = null) {

  function makeRow() {
    currentRow = widget.addStack()
    currentRow.layoutHorizontally()
    currentRow.setPadding(0, 0, 0, 0)
    currentColumn.spacing = 0
    
    // If input was given, make a column of that size.
    if (input > 0) { currentRow.size = new Size(0,input) }
  }
  
  // If there's no input or it's a number, it's being called in the layout declaration.
  if (!input || typeof input == "number") { return makeRow }
  
  // Otherwise, it's being called in the generator.
  else { makeRow() }
}

// Makes a new column on the widget.
function column(input = null) {
 
  function makeColumn() {
    currentColumn = currentRow.addStack()
    currentColumn.layoutVertically()
    currentColumn.setPadding(0, 0, 0, 0)
    currentColumn.spacing = 0
    
    // If input was given, make a column of that size.
    if (input > 0) { currentColumn.size = new Size(input,0) }
  }
  
  // If there's no input or it's a number, it's being called in the layout declaration.
  if (!input || typeof input == "number") { return makeColumn }
  
  // Otherwise, it's being called in the generator.
  else { makeColumn() }
}

// Create an aligned stack to add content to.
function align(column) {
  
  // Add the containing stack to the column.
  let alignmentStack = column.addStack()
  alignmentStack.layoutHorizontally()
  
  // Get the correct stack from the alignment function.
  let returnStack = currentAlignment(alignmentStack)
  returnStack.layoutVertically()
  return returnStack
}

// Create a right-aligned stack.
function alignRight(alignmentStack) {
  alignmentStack.addSpacer()
  let returnStack = alignmentStack.addStack()
  return returnStack
}

// Create a left-aligned stack.
function alignLeft(alignmentStack) {
  let returnStack = alignmentStack.addStack()
  alignmentStack.addSpacer()
  return returnStack
}

// Create a center-aligned stack.
function alignCenter(alignmentStack) {
  alignmentStack.addSpacer()
  let returnStack = alignmentStack.addStack()
  alignmentStack.addSpacer()
  return returnStack
}

// This function adds a space, with an optional amount.
function space(input = null) { 
  
  // This function adds a spacer with the input width.
  function spacer(column) {
  
    // If the input is null or zero, add a flexible spacer.
    if (!input || input == 0) { column.addSpacer() }
    
    // Otherwise, add a space with the specified length.
    else { column.addSpacer(input) }
  }
  
  // If there's no input or it's a number, it's being called in the column declaration.
  if (!input || typeof input == "number") { return spacer }
  
  // Otherwise, it's being called in the column generator.
  else { input.addSpacer() }
}

// Change the current alignment to right.
function right(x) { currentAlignment = alignRight }

// Change the current alignment to left.
function left(x) { currentAlignment = alignLeft }

// Change the current alignment to center.
function center(x) { currentAlignment = alignCenter }

/*
 * SETUP FUNCTIONS
 * These functions prepare data needed for items.
 * ==============================================
 */

// Set up the eventData object.
async function setupEvents() {
  
  eventData = {}
  const calendars = eventSettings.selectCalendars
  const numberOfEvents = eventSettings.numberOfEvents

  // Function to determine if an event should be shown.
  function shouldShowEvent(event) {
  
    // If events are filtered and the calendar isn't in the selected calendars, return false.
    if (calendars.length && !calendars.includes(event.calendar.title)) { return false }

    // Hack to remove canceled Office 365 events.
    if (event.title.startsWith("Canceled:")) { return false }

    // If it's an all-day event, only show if the setting is active.
    if (event.isAllDay) { return eventSettings.showAllDay }

    // Otherwise, return the event if it's in the future.
    return (event.startDate.getTime() > currentDate.getTime())
  }
  
  // Determine which events to show, and how many.
  const todayEvents = await CalendarEvent.today([])
  let shownEvents = 0
  let futureEvents = []
  
  for (const event of todayEvents) {
    if (shownEvents == numberOfEvents) { break }
    if (shouldShowEvent(event)) {
      futureEvents.push(event)
      shownEvents++
    }
  }

  // If there's room and we need to, show tomorrow's events.
  let multipleTomorrowEvents = false
  if (eventSettings.showTomorrow && shownEvents < numberOfEvents) {
  
    const tomorrowEvents = await CalendarEvent.tomorrow([])
    for (const event of tomorrowEvents) {
      if (shownEvents == numberOfEvents) { break }
      if (shouldShowEvent(event)) {
      
        // Add the tomorrow label prior to the first tomorrow event.
        if (!multipleTomorrowEvents) { 
          
          // The tomorrow label is pretending to be an event.
          futureEvents.push({ title: localizedText.tomorrowLabel.toUpperCase(), isLabel: true })
          multipleTomorrowEvents = true
        }
        
        // Show the tomorrow event and increment the counter.
        futureEvents.push(event)
        shownEvents++
      }
    }
  }
  
  // Store the future events, and whether or not any events are displayed.
  eventData.futureEvents = futureEvents
  eventData.eventsAreVisible = (futureEvents.length > 0) && (eventSettings.numberOfEvents > 0)
}

// Set up the gradient for the widget background.
async function setupGradient() {
  
  // Requirements: sunrise
  if (!sunData) { await setupSunrise() }

  let gradient = {
    dawn: {
      color() { return [new Color("142C52"), new Color("1B416F"), new Color("62668B")] },
      position() { return [0, 0.5, 1] },
    },

    sunrise: {
      color() { return [new Color("274875"), new Color("766f8d"), new Color("f0b35e")] },
      position() { return [0, 0.8, 1.5] },
    },

    midday: {
      color() { return [new Color("3a8cc1"), new Color("90c0df")] },
      position() { return [0, 1] },
    },

    noon: {
      color() { return [new Color("b2d0e1"), new Color("80B5DB"), new Color("3a8cc1")] },
      position() { return [-0.2, 0.2, 1.5] },
    },

    sunset: {
      color() { return [new Color("32327A"), new Color("662E55"), new Color("7C2F43")] },
      position() { return [0.1, 0.9, 1.2] },
    },

    twilight: {
      color() { return [new Color("021033"), new Color("16296b"), new Color("414791")] },
      position() { return [0, 0.5, 1] },
    },

    night: {
      color() { return [new Color("16296b"), new Color("021033"), new Color("021033"), new Color("113245")] },
      position() { return [-0.5, 0.2, 0.5, 1] },
    },
  }

  const sunrise = sunData.sunrise
  const sunset = sunData.sunset

  // Use sunrise or sunset if we're within 30min of it.
  if (closeTo(sunrise)<=15) { return gradient.sunrise }
  if (closeTo(sunset)<=15) { return gradient.sunset }

  // In the 30min before/after, use dawn/twilight.
  if (closeTo(sunrise)<=45 && currentDate.getTime() < sunrise) { return gradient.dawn }
  if (closeTo(sunset)<=45 && currentDate.getTime() > sunset) { return gradient.twilight }

  // Otherwise, if it's night, return night.
  if (isNight(currentDate)) { return gradient.night }

  // If it's around noon, the sun is high in the sky.
  if (currentDate.getHours() == 12) { return gradient.noon }

  // Otherwise, return the "typical" theme.
  return gradient.midday
}

// Set up the locationData object.
async function setupLocation() {

  locationData = {}
  const locationPath = files.joinPath(files.documentsDirectory(), "weather-cal-loc")

  // If our location is unlocked or cache doesn't exist, ask iOS for location.
  var readLocationFromFile = false
  if (!lockLocation || !files.fileExists(locationPath)) {
    try {
      const location = await Location.current()
      const geocode = await Location.reverseGeocode(location.latitude, location.longitude, locale)
      locationData.latitude = location.latitude
      locationData.longitude = location.longitude
      locationData.locality = geocode[0].locality
      files.writeString(locationPath, location.latitude + "|" + location.longitude + "|" + locationData.locality)
    
    } catch(e) {
      // If we fail in unlocked mode, read it from the cache.
      if (!lockLocation) { readLocationFromFile = true }
      
      // We can't recover if we fail on first run in locked mode.
      else { return }
    }
  }
  
  // If our location is locked or we need to read from file, do it.
  if (lockLocation || readLocationFromFile) {
    const locationStr = files.readString(locationPath).split("|")
    locationData.latitude = locationStr[0]
    locationData.longitude = locationStr[1]
    locationData.locality = locationStr[2]
  }
}

// Set up the sunData object.
async function setupSunrise() {

  // Requirements: location
  if (!locationData) { await setupLocation() }
  
  async function getSunData(date) {
    const req = "https://api.sunrise-sunset.org/json?lat=" + locationData.latitude + "&lng=" + locationData.longitude + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth()+1) + "-" + date.getDate()
    const data = await new Request(req).loadJSON()
    return data
  }

  // Set up the sunrise/sunset cache.
  const sunCachePath = files.joinPath(files.documentsDirectory(), "weather-cal-sunrise")
  const sunCacheExists = files.fileExists(sunCachePath)
  const sunCacheDate = sunCacheExists ? files.modificationDate(sunCachePath) : 0
  let sunDataRaw

  // If cache exists and was created today, use cached data.
  if (sunCacheExists && sameDay(currentDate, sunCacheDate)) {
    const sunCache = files.readString(sunCachePath)
    sunDataRaw = JSON.parse(sunCache)
  }
  
  // Otherwise, get the data from the server.
  else {

    sunDataRaw = await getSunData(currentDate)
  
    // Calculate tomorrow's date and get tomorrow's data.
    let tomorrowDate = new Date()
    tomorrowDate.setDate(currentDate.getDate() + 1)
    const tomorrowData = await getSunData(tomorrowDate)
    sunDataRaw.results.tomorrow = tomorrowData.results.sunrise
    
    // Cache the file.
    files.writeString(sunCachePath, JSON.stringify(sunDataRaw))
  }

  // Store the timing values.
  sunData = {}
  sunData.sunrise = new Date(sunDataRaw.results.sunrise).getTime()
  sunData.sunset = new Date(sunDataRaw.results.sunset).getTime()
  sunData.tomorrow = new Date(sunDataRaw.results.tomorrow).getTime()
}

// Set up the weatherData object.
async function setupWeather() {

  // Requirements: location
  if (!locationData) { await setupLocation() }

  // Set up the cache.
  const cachePath = files.joinPath(files.documentsDirectory(), "weather-cal-cache")
  const cacheExists = files.fileExists(cachePath)
  const cacheDate = cacheExists ? files.modificationDate(cachePath) : 0
  var weatherDataRaw

  // If cache exists and it's been less than 60 seconds since last request, use cached data.
  if (cacheExists && (currentDate.getTime() - cacheDate.getTime()) < 60000) {
    const cache = files.readString(cachePath)
    weatherDataRaw = JSON.parse(cache)

  // Otherwise, use the API to get new weather data.
  } else {
    const weatherReq = "https://api.openweathermap.org/data/2.5/onecall?lat=" + locationData.latitude + "&lon=" + locationData.longitude + "&exclude=minutely,alerts&units=" + weatherSettings.units + "&lang=" + locale + "&appid=" + apiKey
    weatherDataRaw = await new Request(weatherReq).loadJSON()
    files.writeString(cachePath, JSON.stringify(weatherDataRaw))
  }

  // Store the weather values.
  weatherData = {}
  weatherData.currentTemp = weatherDataRaw.current.temp
  weatherData.currentCondition = weatherDataRaw.current.weather[0].id
  weatherData.currentDescription = weatherDataRaw.current.weather[0].main
  weatherData.todayHigh = weatherDataRaw.daily[0].temp.max
  weatherData.todayLow = weatherDataRaw.daily[0].temp.min

  weatherData.nextHourTemp = weatherDataRaw.hourly[1].temp
  weatherData.nextHourCondition = weatherDataRaw.hourly[1].weather[0].id

  weatherData.tomorrowHigh = weatherDataRaw.daily[1].temp.max
  weatherData.tomorrowLow = weatherDataRaw.daily[1].temp.min
  weatherData.tomorrowCondition = weatherDataRaw.daily[1].weather[0].id
}

/*
 * WIDGET ITEMS
 * These functions display items on the widget.
 * ============================================
 */

// Display the date on the widget.
async function date(column) {

  // Requirements: events (if dynamicDateSize is enabled)
  if (!eventData && dateSettings.dynamicDateSize) { await setupEvents() }

  // Set up the date formatter and set its locale.
  let df = new DateFormatter()
  df.locale = locale
  
  // Show small if it's hard coded, or if it's dynamic and events are visible.
  if (dateSettings.staticDateSize == "small" || (dateSettings.dynamicDateSize && eventData.eventsAreVisible)) {
    let dateStack = align(column)
    dateStack.setPadding(padding, padding, padding, padding)

    df.dateFormat = dateSettings.smallDateFormat
    let dateText = provideText(df.string(currentDate), dateStack, textFormat.smallDate)
    
  // Otherwise, show the large date.
  } else {
    let dateOneStack = align(column)
    df.dateFormat = dateSettings.largeDateLineOne
    let dateOne = provideText(df.string(currentDate), dateOneStack, textFormat.largeDate1)
    dateOneStack.setPadding(padding/2, padding, 0, padding)
    
    let dateTwoStack = align(column)
    df.dateFormat = dateSettings.largeDateLineTwo
    let dateTwo = provideText(df.string(currentDate), dateTwoStack, textFormat.largeDate2)
    dateTwoStack.setPadding(0, padding, padding, padding)
  }
}

// Display a time-based greeting on the widget.
async function greeting(column) {

  // This function makes a greeting based on the time of day.
  function makeGreeting() {
    const hour = currentDate.getHours()
    if (hour    < 5)  { return localizedText.nightGreeting }
    if (hour    < 12) { return localizedText.morningGreeting }
    if (hour-12 < 5)  { return localizedText.afternoonGreeting }
    if (hour-12 < 10) { return localizedText.eveningGreeting }
    return localizedText.nightGreeting
  }
  
  // Set up the greeting.
  let greetingStack = align(column)
  let greeting = provideText(makeGreeting(), greetingStack, textFormat.greeting)
  greetingStack.setPadding(padding, padding, padding, padding)
}

// Display events on the widget.
async function events(column) {

  // Requirements: events
  if (!eventData) { await setupEvents() }

  // If no events are visible, figure out what to do.
  if (!eventData.eventsAreVisible) { 
    const display = eventSettings.noEventBehavior
    
    // If it's a greeting, let the greeting function handle it.
    if (display == "greeting") { return await greeting(column) }
    
    // If it's a message, get the localized text.
    if (display == "message" && localizedText.noEventMessage.length) {
      const messageStack = align(column)
      messageStack.setPadding(padding, padding, padding, padding)
      provideText(localizedText.noEventMessage, messageStack, textFormat.noEvents)
    }
    
    // Whether or not we displayed something, return here.
    return
  }
  
  // Set up the event stack.
  let eventStack = column.addStack()
  eventStack.layoutVertically()
  const todaySeconds = Math.floor(currentDate.getTime() / 1000) - 978307200
  eventStack.url = 'calendars://'
  
  // If there are no events and we have a message, show it and return.
  if (!eventData.eventsAreVisible && localizedText.noEventMessage.length) {
    let message = provideText(localizedText.noEventMessage, eventStack, textFormat.noEvents)
    eventStack.setPadding(padding, padding, padding, padding)
    return
  }
  
  // If we're not showing the message, don't pad the event stack.
  eventStack.setPadding(20, 0, 0, 0)
  
  // Add each event to the stack.
  var currentStack = eventStack
  const futureEvents = eventData.futureEvents
  for (let i = 0; i < futureEvents.length; i++) {
    
    const event = futureEvents[i]
    const bottomPadding = (padding-10 < 0) ? 0 : padding-10
    
    // If it's the tomorrow label, change to the tomorrow stack.
    if (event.isLabel) {
      let tomorrowStack = column.addStack()
      tomorrowStack.layoutVertically()
      const tomorrowSeconds = Math.floor(currentDate.getTime() / 1000) - 978220800
      tomorrowStack.url = 'calendars://'
      currentStack = tomorrowStack
      
      // Mimic the formatting of an event title, mostly.
      const eventLabelStack = align(currentStack)
      const eventLabel = provideText(event.title, eventLabelStack, textFormat.eventLabel)
      eventLabelStack.setPadding(10, 0, 0, padding)
      continue
    }
    
    const titleStack = align(currentStack)
    titleStack.layoutHorizontally()
    const showCalendarColor = eventSettings.showCalendarColor
    const colorShape = showCalendarColor.includes("circle") ? "circle" : "rectangle"
    
    // If we're showing a color, and it's not shown on the right, add it to the left.
    if (showCalendarColor.length && !showCalendarColor.includes("right")) {
      let colorItemText = provideTextSymbol(colorShape) + " "
      let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
      colorItem.textColor = event.calendar.color
    }

    const title = provideText(event.title.trim(), titleStack, textFormat.eventTitle)
    titleStack.setPadding(padding, padding, event.isAllDay ? padding : padding/5, padding)
    
    // If we're showing a color on the right, show it.
    if (showCalendarColor.length && showCalendarColor.includes("right")) {
      let colorItemText = " " + provideTextSymbol(colorShape)
      let colorItem = provideText(colorItemText, titleStack, textFormat.eventTitle)
      colorItem.textColor = event.calendar.color
    }
  
    // If there are too many events, limit the line height.
    if (futureEvents.length >= 3) { title.lineLimit = 1 }

    // If it's an all-day event, we don't need a time.
    if (event.isAllDay) { continue }
    
    // Format the time information.
    let timeText = formatTime(event.startDate)
    
    // If we show the length as time, add an en dash and the time.
    if (eventSettings.showEventLength == "time") { 
      timeText += "–" + formatTime(event.endDate) 
      
    // If we should it as a duration, add the minutes.
    } else if (eventSettings.showEventLength == "duration") {
      const duration = (event.endDate.getTime() - event.startDate.getTime()) / (1000*60)
      const hours = Math.floor(duration/60)
      const minutes = Math.floor(duration % 60)
      const hourText = hours>0 ? hours + localizedText.durationHour : ""
      const minuteText = minutes>0 ? minutes + localizedText.durationMinute : ""
      const showSpace = hourText.length && minuteText.length
      timeText += " \u2022 " + hourText + (showSpace ? " " : "") + minuteText
    }

    const timeStack = align(currentStack)
    const time = provideText(timeText, timeStack, textFormat.eventTime)
    timeStack.setPadding(-3, 9, padding, padding)
  }
}

// Display the current weather.
async function current(column) {

  // Requirements: weather and sunrise
  if (!weatherData) { await setupWeather() }
  if (!sunData) { await setupSunrise() }

  // Set up the current weather stack.
  let currentWeatherStack = column.addStack()
  currentWeatherStack.layoutVertically()
  currentWeatherStack.setPadding(6, 0, 0, 0)
  currentWeatherStack.url = "https://weather.naver.com/"
  
  // If we're showing the location, add it.
  if (weatherSettings.showLocation) {
    let locationTextStack = align(currentWeatherStack)
    let locationText = provideText(locationData.locality, locationTextStack, textFormat.smallTemp)
    locationTextStack.setPadding(padding, padding, padding, padding)
  }

  // Show the current condition symbol.
  let mainConditionStack = align(currentWeatherStack)
  let mainCondition = mainConditionStack.addImage(provideConditionSymbol(weatherData.currentCondition,isNight(currentDate)))
  mainCondition.imageSize = new Size(35,35)
  tintIcon(mainCondition, textFormat.largeTemp)
  mainConditionStack.setPadding(weatherSettings.showLocation ? 0 : padding, padding, 0, padding)
  
  // If we're showing the description, add it.
  if (weatherSettings.showCondition) {
    let conditionTextStack = align(currentWeatherStack)
    let conditionText = provideText(weatherData.currentDescription, conditionTextStack, textFormat.smallTemp)
    conditionTextStack.setPadding(padding, padding, 0, padding)
  }

  // Show the current temperature.
  const tempStack = align(currentWeatherStack)
  tempStack.setPadding(0, padding, 0, padding)
  const tempText = Math.round(weatherData.currentTemp) + "°"
  const temp = provideText(tempText, tempStack, textFormat.largeTemp)
  
  // If we're not showing the high and low, end it here.
  if (!weatherSettings.showHighLow) { return }

  // Show the temp bar and high/low values.
  let tempBarStack = align(currentWeatherStack)
  tempBarStack.layoutVertically()
  tempBarStack.setPadding(0, padding, padding, padding)
  
  let tempBar = drawTempBar()
  let tempBarImage = tempBarStack.addImage(tempBar)
  tempBarImage.size = new Size(50,0)
  
  tempBarStack.addSpacer(1)
  
  let highLowStack = tempBarStack.addStack()
  highLowStack.layoutHorizontally()
  
  const mainLowText = Math.round(weatherData.todayLow).toString()
  const mainLow = provideText(mainLowText, highLowStack, textFormat.tinyTemp)
  highLowStack.addSpacer()
  const mainHighText = Math.round(weatherData.todayHigh).toString()
  const mainHigh = provideText(mainHighText, highLowStack, textFormat.tinyTemp)
  
  tempBarStack.size = new Size(60,30)
}

// Display upcoming weather.
async function future(column) {

  // Requirements: weather and sunrise
  if (!weatherData) { await setupWeather() }
  if (!sunData) { await setupSunrise() }

  // Set up the future weather stack.
  let futureWeatherStack = column.addStack()
  futureWeatherStack.layoutVertically()
  futureWeatherStack.setPadding(0, 0, 0, 0)
  futureWeatherStack.url = "https://weather.naver.com/"

  // Determine if we should show the next hour.
  const showNextHour = (currentDate.getHours() < weatherSettings.tomorrowShownAtHour)
  
  // Set the label value.
  const subLabelStack = align(futureWeatherStack)
  const subLabelText = showNextHour ? localizedText.nextHourLabel : localizedText.tomorrowLabel
  const subLabel = provideText(subLabelText, subLabelStack, textFormat.smallTemp)
  subLabelStack.setPadding(0, padding, padding/2, padding)
  
  // Set up the sub condition stack.
  let subConditionStack = align(futureWeatherStack)
  subConditionStack.layoutHorizontally()
  subConditionStack.centerAlignContent()
  subConditionStack.setPadding(0, padding, padding, padding)
  
  // Determine if it will be night in the next hour.
  var nightCondition
  if (showNextHour) {
    const addHour = currentDate.getTime() + (60*60*1000)
    const newDate = new Date(addHour)
    nightCondition = isNight(newDate)
  } else {
    nightCondition = false 
  }
  
  let subCondition = subConditionStack.addImage(provideConditionSymbol(showNextHour ? weatherData.nextHourCondition : weatherData.tomorrowCondition,nightCondition))
  const subConditionSize = showNextHour ? 14 : 18
  subCondition.imageSize = new Size(subConditionSize, subConditionSize)
  tintIcon(subCondition, textFormat.smallTemp)
  subConditionStack.addSpacer(5)
  
  // The next part of the display changes significantly for next hour vs tomorrow.
  if (showNextHour) {
    const subTempText = Math.round(weatherData.nextHourTemp) + "°"
    const subTemp = provideText(subTempText, subConditionStack, textFormat.smallTemp)
    
  } else {
    let tomorrowLine = subConditionStack.addImage(drawVerticalLine(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5), 20))
    tomorrowLine.imageSize = new Size(3,28)
    subConditionStack.addSpacer(5)
    let tomorrowStack = subConditionStack.addStack()
    tomorrowStack.layoutVertically()
    
    const tomorrowHighText = Math.round(weatherData.tomorrowHigh) + ""
    const tomorrowHigh = provideText(tomorrowHighText, tomorrowStack, textFormat.tinyTemp)
    tomorrowStack.addSpacer(4)
    const tomorrowLowText = Math.round(weatherData.tomorrowLow) + ""
    const tomorrowLow = provideText(tomorrowLowText, tomorrowStack, textFormat.tinyTemp)
  }
}

// Return a text-creation function.
function text(input = null) {

  function displayText(column) {
  
    // Don't do anything if the input is blank.
    if (!input || input == "") { return }
  
    // Otherwise, add the text.
    const textStack = align(column)
    textStack.setPadding(padding, padding, padding, padding)
    const textDisplay = provideText(input, textStack, textFormat.customText)
  }
  return displayText
}

// Add a battery element to the widget; consisting of a battery icon and percentage.
async function battery(column) {

  // Get battery level via Scriptable function and format it in a convenient way
  function getBatteryLevel() {
  
    const batteryLevel = Device.batteryLevel()
    const batteryPercentage = `${Math.round(batteryLevel * 100)}%`

    return batteryPercentage
  }
  
  const batteryLevel = Device.batteryLevel()
  
  // Set up the battery level item
  let batteryStack = align(column)
  batteryStack.layoutHorizontally()
  batteryStack.centerAlignContent()

  let batteryIcon = batteryStack.addImage(provideBatteryIcon())
  batteryIcon.imageSize = new Size(18,18)
  
  // Change the battery icon to red if battery level is <= 20 to match system behavior
  if ( Math.round(batteryLevel * 100) > 20 || Device.isCharging() ) {

    tintIcon(batteryIcon, textFormat.battery)

  } else {

    batteryIcon.tintColor = Color.red()

  }

  batteryStack.addSpacer(padding * 0.6)

  // Display the battery status
  let batteryInfo = provideText(getBatteryLevel(), batteryStack, textFormat.battery)

  batteryStack.setPadding(padding/2, padding, padding/2, padding)

}

// Show the sunrise or sunset time.
async function sunrise(column) {
  
  // Requirements: sunrise
  if (!sunData) { await setupSunrise() }
  
  const sunrise = sunData.sunrise
  const sunset = sunData.sunset
  const tomorrow = sunData.tomorrow
  const current = currentDate.getTime()
  
  const showWithin = sunriseSettings.showWithin
  const closeToSunrise = closeTo(sunrise) <= showWithin
  const closeToSunset = closeTo(sunset) <= showWithin

  // If we only show sometimes and we're not close, return.
  if (showWithin > 0 && !closeToSunrise && !closeToSunset) { return }
  
  // Otherwise, determine which time to show.
  let timeToShow, symbolName
  const halfHour = 30 * 60 * 1000
  
  // If we're between sunrise and sunset, show the sunset.
  if (current > sunrise + halfHour && current < sunset + halfHour) {
    symbolName = "sunset.fill"
    timeToShow = sunset
  }
  
  // Otherwise, show a sunrise.
  else {
    symbolName = "sunrise.fill"
    timeToShow = current > sunset ? tomorrow : sunrise
  }
  
  // Set up the stack.
  const sunriseStack = align(column)
  sunriseStack.setPadding(padding/2, padding, padding/2, padding)
  sunriseStack.layoutHorizontally()
  sunriseStack.centerAlignContent()
  
  sunriseStack.addSpacer(padding * 0.3)
  
  // Add the correct symbol.
  const symbol = sunriseStack.addImage(SFSymbol.named(symbolName).image)
  symbol.imageSize = new Size(18,18)
  tintIcon(symbol, textFormat.sunrise)
  
  sunriseStack.addSpacer(padding)
  sunriseStack.url = "https://weather.naver.com/"
  
  // Add the time.
  const timeText = formatTime(new Date(timeToShow))
  const time = provideText(timeText, sunriseStack, textFormat.sunrise)
}

// Allow for either term to be used.
async function sunset(column) {
  return await sunrise(column)
}


/*
 * HELPER FUNCTIONS
 * These functions perform duties for other functions.
 * ===================================================
 */

// Tints icons if needed.
function tintIcon(icon,format) {
  if (!tintIcons) { return }
  icon.tintColor = new Color(format.color || textFormat.defaultText.color)
}

// Determines if the provided date is at night.
function isNight(dateInput) {
  const timeValue = dateInput.getTime()
  return (timeValue < sunData.sunrise) || (timeValue > sunData.sunset)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}

// Returns the number of minutes between now and the provided date.
function closeTo(time) {
  return Math.abs(currentDate.getTime() - time) / 60000
}

// Format the time for a Date input.
function formatTime(date) {
  let df = new DateFormatter()
  df.locale = locale
  df.useNoDateStyle()
  df.useShortTimeStyle()
  return df.string(date)
}

// Provide a text symbol with the specified shape.
function provideTextSymbol(shape) {

  // Rectangle character.
  if (shape.startsWith("rect")) {
    return "\u2759"
  }
  // Circle character.
  if (shape == "circle") {
    return "\u2B24"
  }
  // Default to the rectangle.
  return "\u2759" 
}

// Provide a battery SFSymbol with accurate level drawn on top of it.
function provideBatteryIcon() {
  
  // If we're charging, show the charging icon.
  if (Device.isCharging()) { return SFSymbol.named("battery.100.bolt").image }
  
  // Set the size of the battery icon.
  const batteryWidth = 87
  const batteryHeight = 41
  
  // Start our draw context.
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(batteryWidth, batteryHeight)
  
  // Draw the battery.
  draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
  
  // Match the battery level values to the SFSymbol.
  const x = batteryWidth*0.1525
  const y = batteryHeight*0.247
  const width = batteryWidth*0.602
  const height = batteryHeight*0.505
  
  // Prevent unreadable icons.
  let level = Device.batteryLevel()
  if (level < 0.05) { level = 0.05 }
  
  // Determine the width and radius of the battery level.
  const current = width * level
  let radius = height/6.5
  
  // When it gets low, adjust the radius to match.
  if (current < (radius * 2)) { radius = current / 2 }

  // Make the path for the battery level.
  let barPath = new Path()
  barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
  draw.addPath(barPath)
  const color = tintIcons ? (textFormat.battery.color || textFormat.defaultText.color) : "000000"
  draw.setFillColor(new Color(color))
  draw.fillPath()
  return draw.getImage()
}

// Provide a symbol based on the condition.
function provideConditionSymbol(cond,night) {
  
  // Define our symbol equivalencies.
  let symbols = {
  
    // Thunderstorm
    "2": function() { return "cloud.bolt.rain.fill" },
    
    // Drizzle
    "3": function() { return "cloud.drizzle.fill" },
    
    // Rain
    "5": function() { return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" },
    
    // Snow
    "6": function() { return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" },
    
    // Atmosphere
    "7": function() {
      if (cond == 781) { return "tornado" }
      if (cond == 701 || cond == 741) { return "cloud.fog.fill" }
      return night ? "cloud.fog.fill" : "sun.haze.fill"
    },
    
    // Clear and clouds
    "8": function() {
      if (cond == 800 || cond == 801) { return night ? "moon.stars.fill" : "sun.max.fill" }
      if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" }
      return "cloud.fill"
    }
  }
  
  // Find out the first digit.
  let conditionDigit = Math.floor(cond / 100)
  
  // Get the symbol.
  return SFSymbol.named(symbols[conditionDigit]()).image
}

// Provide a font based on the input.
function provideFont(fontName, fontSize) {
  const fontGenerator = {
    "ultralight": function() { return Font.ultraLightSystemFont(fontSize) },
    "light": function() { return Font.lightSystemFont(fontSize) },
    "regular": function() { return Font.regularSystemFont(fontSize) },
    "medium": function() { return Font.mediumSystemFont(fontSize) },
    "semibold": function() { return Font.semiboldSystemFont(fontSize) },
    "bold": function() { return Font.boldSystemFont(fontSize) },
    "heavy": function() { return Font.heavySystemFont(fontSize) },
    "black": function() { return Font.blackSystemFont(fontSize) },
    "italic": function() { return Font.italicSystemFont(fontSize) }
  }
  
  const systemFont = fontGenerator[fontName]
  if (systemFont) { return systemFont() }
  return new Font(fontName, fontSize)
}
 
// Add formatted text to a container.
function provideText(string, container, format) {
  const textItem = container.addText(string)
  const textFont = format.font || textFormat.defaultText.font
  const textSize = format.size || textFormat.defaultText.size
  const textColor = format.color || textFormat.defaultText.color
  
  textItem.font = provideFont(textFont, textSize)
  textItem.textColor = new Color(textColor)
  return textItem
}

/*
 * DRAWING FUNCTIONS
 * These functions draw onto a canvas.
 * ===================================
 */

// Draw the vertical line in the tomorrow view.
function drawVerticalLine(color, height) {
  
  const width = 2
  
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(width,height)
  
  let barPath = new Path()
  const barHeight = height
  barPath.addRoundedRect(new Rect(0, 0, width, height), width/2, width/2)
  draw.addPath(barPath)
  draw.setFillColor(color)
  draw.fillPath()
  
  return draw.getImage()
}

// Draw the temp bar.
function drawTempBar() {

  // Set the size of the temp bar.
  const tempBarWidth = 250
  const tempBarHeight = 20
  
  // Calculate the current percentage of the high-low range.
  let percent = (weatherData.currentTemp - weatherData.todayLow) / (weatherData.todayHigh - weatherData.todayLow)

  // If we're out of bounds, clip it.
  if (percent < 0) {
    percent = 0
  } else if (percent > 1) {
    percent = 1
  }

  // Determine the scaled x-value for the current temp.
  const currPosition = (tempBarWidth - tempBarHeight) * percent

  // Start our draw context.
  let draw = new DrawContext()
  draw.opaque = false
  draw.respectScreenScale = true
  draw.size = new Size(tempBarWidth, tempBarHeight)

  // Make the path for the bar.
  let barPath = new Path()
  const barHeight = tempBarHeight - 10
  barPath.addRoundedRect(new Rect(0, 5, tempBarWidth, barHeight), barHeight / 2, barHeight / 2)
  draw.addPath(barPath)
  
  // Determine the color.
  const barColor = textFormat.battery.color || textFormat.defaultText.color
  draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 0.5))
  draw.fillPath()

  // Make the path for the current temp indicator.
  let currPath = new Path()
  currPath.addEllipse(new Rect(currPosition, 0, tempBarHeight, tempBarHeight))
  draw.addPath(currPath)
  draw.setFillColor(new Color(textFormat.tinyTemp.color || textFormat.defaultText.color, 1))
  draw.fillPath()

  return draw.getImage()
}

8. 다른 사용자들의 Scriptable 위젯 사용 화면

full-weatherline-widget 은 12시간, 일주일 단위의 날씨 예보를 꺾은선 그래프로 보여줍니다.

Scriptable 서브 레딧에 방문하면 재밌는 코드와 배열 아이디어를 얻을 수 있습니다. 축구 스코어 표시하기, 코로나 확진자와 사망자 표시, 달의 위상 표시, 환율과 시스템 메모리 사용량 표시, 습관 추적하기, 매일 할일 기록, 삼성스타일 화면, 터미널 스타일로 각종 정보 표시 등등..

국내에도 한국 기준 코로나 확진자 표시를 위젯으로 구현하신 분도 계시고, 픽셀 스타일로 가장 임박한 일정만 심플하게 띄워주는 위젯도 제작하신 분이 계세요.

9. 나오며

앞으로 더 기대됩니다. 만들 능력은 안 되지만, Scriptable을 통해서 아이폰 첫 화면에 더 자유롭게 필요한 정보를 띄워놓을 수 있게 되었으니까요. 다만 많은 정보를 표시할수록 램 용량을 사용하게 되고, 아이폰 성능에 영향을 줄 수 있다는 사실을 기억하세요.

(2020년 11월)

5 replies on “아이폰 날씨+달력 투명 위젯 만들기 (ft. Scriptable)”

Error on line 766:51: TypeError: undefined is not an object (evaluating ‘weatherDataRaw.Current.temp’) 라고 나오는건 어떻게 해야할까요 ㅠㅠ?

캘린더가 하나밖에 안나와요!! 어떻게 수정해야 다른 캘린더도 나오느오? 추가 한다고 했는데도 안되네오

Leave a Reply

Your email address will not be published.