Overview

Calendar Vision allow syncing events via an iCalendar feed but I wanted to give users a way to add events directly to their calendar. Since iOS and Android both have native APIs for adding events to the calendar, i could use a Hotwire Native Bridge Component to trigger them from within a Rails view. I created an Add to Calendar Bridge Component that triggers the respective native APIs when a user clicks the “Add to Calendar” button in a Rails view.

Add to Calendar on iOS

Rails implementation

The first step is to create the Rails-side of the bridge component and trigger it within a Rails view. This requires a Stimulus controller that extends the BridgeComponent class. The only action we need on the controller is the add action. This will be called when the user clicks the “Add to Calendar” button. We need to pass a JSON representation of the event to the native bridge component so we’ll store that in a Stimulus value. We also provide a “reply” function that will be called when the native bridge component calls the “reply” method. This will be used to mark the event as added to their calendar in our database.

import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "add-to-calendar"
  static values = {
    eventPayload: Object,
    markAddedUrl: String,
  }

  add() {
    this.send("add", this.eventPayloadValue, () => {
      fetch(this.markAddedUrlValue, {
        method: "PUT",
        headers: { "X-CSRF-Token": this.#csrfToken },
      })
    })
  }

  get #csrfToken() {
    return document.querySelector("meta[name='csrf-token']")?.content
  }
}
app/javascript/controllers/bridge/add_to_calendar_controller.js

The view needs a button connected to the Add to Calendar bridge component. We add attributes to the button for the click event, calendar event JSON and the URL to mark the event as added.

<%# locals: (calendar_event:) %>
<% payload = calendar_event_json_payload(calendar_event) %>
<%= button_tag data: {
  controller: "bridge--add-to-calendar",
  action: "bridge--add-to-calendar#add",
  bridge__add_to_calendar_event_payload_value: payload.to_json,
  bridge__add_to_calendar_mark_added_url_value: added_events_calendar_calendar_event_path(calendar_event)
} do %>
  Add to Calendar
<% end %>
app/views/shared/_add_to_calendar.html.erb

iOS implementation

When the button is clicked, we need to open the native calendar modal in the iOS app. We get the calendar event data from the message and build an EKEvent object. We present an EKEventEditViewController which opens the native add to calendar modal.

When the user saves the event, we call the reply function to notify the Rails side that the event was added to the calendar. If the user discards the event, we don’t call the reply function.

import EventKit
import EventKitUI
import HotwireNative
import UIKit

final class AddToCalendarComponent: BridgeComponent {
    override class var name: String { "add-to-calendar" }

    private var viewController: UIViewController? {
        delegate?.destination as? UIViewController
    }

    private var eventEditDelegate: EventEditDelegate?

    override func onReceive(message: Message) {
        guard message.event == Event.add.rawValue,
              let calendarEvent: CalendarEvent = message.data() else {
            return
        }

        Task { await presentEventEditor(calendarEvent: calendarEvent, message: message) }
    }

    private func presentEventEditor(calendarEvent: CalendarEvent, message: Message) async {
        let event = EKEventBuilder.makeEvent(from: calendarEvent, eventStore: CalendarEventStore.shared)

        await MainActor.run {
            let editDelegate = EventEditDelegate { [weak self] in
                self?.eventEditDelegate = nil
                self?.reply(to: Event.add.rawValue)
            }
            eventEditDelegate = editDelegate

            let eventViewController = EKEventEditViewController()
            eventViewController.event = event
            eventViewController.eventStore = CalendarEventStore.shared
            eventViewController.editViewDelegate = editDelegate
            viewController?.present(eventViewController, animated: true)
        }
    }
}

// MARK: - Types

private extension AddToCalendarComponent {
    enum Event: String {
        case add
    }
}

// MARK: - EKEventEditViewDelegate

private final class EventEditDelegate: NSObject, EKEventEditViewDelegate {
    private let onSaved: () -> Void

    init(onSaved: @escaping () -> Void) {
        self.onSaved = onSaved
    }

    func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) {
        controller.dismiss(animated: true)
        
        guard action == .saved else { return }
        
        onSaved()
    }
}
AddToCalendarComponent.swift

Android implementation

Android does not have a native calendar model. Instead, we use an Intent to open the native calendar app and allow the user to add the event within their calendar app.

Unlike iOS, we don’t reply to the Stimulus controller since we don’t know whether the user added the event to their calendar. Alternatively, you could choose to always reply. If you know of a way to determine whether the user added the event to their calendar, please let me know.

class AddToCalendarComponent(
    name: String,
    private val bridgeDelegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, bridgeDelegate) {
    override fun onReceive(message: Message) {
        when (message.event) {
            "add" -> handleAddToCalendar(message)
            else -> {}
        }
    }

    private fun handleAddToCalendar(message: Message) {
        val calendarEvent = message.data<CalendarEvent>() ?: run {
            return
        }

        val activity = bridgeDelegate.destination.fragment.requireActivity()
        openCalendarEventInIntent(calendarEvent, activity)
    }

    private fun openCalendarEventInIntent(
        calendarEvent: CalendarEvent,
        activity: android.app.Activity
    ) {
        var startsAtInMillisecondsSinceEpoch = calendarEvent.startsAtInMillisecondsSinceEpoch
        var endsAtInMillisecondsSinceEpoch = calendarEvent.endsAtInMillisecondsSinceEpoch

        if (calendarEvent.allDay) {
            startsAtInMillisecondsSinceEpoch =
                calendarEvent.allDayStartAtInMillisecondsSinceEpoch
            endsAtInMillisecondsSinceEpoch =
                calendarEvent.allDayEndsAtInMillisecondsSinceEpoch
        }

        var intent = Intent(Intent.ACTION_INSERT)
            .setData(CalendarContract.Events.CONTENT_URI)
            .putExtra(CalendarContract.Events.TITLE, calendarEvent.name)
            .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startsAtInMillisecondsSinceEpoch)
            .putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endsAtInMillisecondsSinceEpoch)
            .putExtra(CalendarContract.Events.ALL_DAY, calendarEvent.allDay)

        if (!calendarEvent.location.isNullOrEmpty()) {
            intent = intent.putExtra(CalendarContract.Events.EVENT_LOCATION, calendarEvent.location)
        }

        if (calendarEvent.rrule != null && !calendarEvent.rrule.isEmpty()) {
            intent = intent.putExtra(CalendarContract.Events.RRULE, calendarEvent.rrule)
        }

        activity.startActivity(intent)
    }
}
AddToCalendarComponent.kt