defmodule ReflectOS.Core.Sections.Calendar do
use ReflectOS.Kernel.Section
require Logger
alias Scenic.Graph
alias Scenic.Scene
alias Scenic.Assets.Static
import Scenic.Primitives
alias ICalendar.Event
import Phoenix.Component, only: [sigil_H: 2]
alias ReflectOS.Kernel.{Option, OptionGroup}
import ReflectOS.Kernel.Primitives, only: [render_section_label: 3]
import ReflectOS.Kernel.Typography
alias ReflectOS.Kernel.Section.Definition
alias ReflectOS.Kernel.Settings.System
alias ReflectOS.Core.Sections.Calendar.Service
embedded_schema do
field(:show_label?, :boolean, default: false)
field(:label, :string, default: "Calendar")
field(:max_displayed_events, :integer, default: 5)
field(:polling_interval_seconds, :integer, default: 60)
field(:ical_url_1, :string)
field(:ical_url_2, :string)
field(:ical_url_3, :string)
field(:ical_url_4, :string)
field(:ical_url_5, :string)
end
@impl true
def changeset(%__MODULE__{} = section, params \\ %{}) do
section
|> cast(params, [
:show_label?,
:label,
:max_displayed_events,
:polling_interval_seconds,
:ical_url_1,
:ical_url_2,
:ical_url_3,
:ical_url_4,
:ical_url_5
])
|> validate_required([
:show_label?,
:max_displayed_events,
:polling_interval_seconds,
:ical_url_1
])
end
@impl true
def section_definition(),
do: %Definition{
name: "Calendar",
icon: """
"""
}
@impl true
def section_options(),
do: [
# Label
%OptionGroup{
label: "Label",
options: [
%Option{
key: :show_label?,
label: "Show Label",
config: %{
type: "checkbox"
}
},
%Option{
key: :label,
label: "Label Text",
hidden: fn %{show_label?: show_label?} ->
!show_label?
end
}
]
},
%OptionGroup{
label: "Calendars",
description: fn assigns ->
~H"""
Calendar Urls in iCal format (usually ends with .ics).
Many calendar systems provide this type of feed. For example,
instructions for Google Calendar can be found
here.
You can add up to five calendars.
"""
end,
options: [
# iCal URLs
%Option{
key: :ical_url_1,
label: "Calendar Url 1"
},
%Option{
key: :ical_url_2,
label: "Calendar Url 2"
},
%Option{
key: :ical_url_3,
label: "Calendar Url 3"
},
%Option{
key: :ical_url_4,
label: "Calendar Url 4"
},
%Option{
key: :ical_url_5,
label: "Calendar Url 5"
}
]
},
%OptionGroup{
label: "Display",
options: [
# Max displayed events
%Option{
key: :max_displayed_events,
label: "Number of displayed events",
config: %{
type: "number"
}
},
# Refresh Interval
%Option{
key: :polling_interval_seconds,
label: "Refresh Interval (seconds)",
config: %{
type: "number"
}
}
]
}
]
@time_width 96
@total_width 430
@summary_width @total_width - @time_width
@summary_style %{
font_size: 24,
font: :roboto
}
@doc false
@impl ReflectOS.Kernel.Section
def init_section(
scene,
%__MODULE__{
polling_interval_seconds: polling_interval_seconds,
max_displayed_events: max_displayed_events,
label: calendar_name,
show_label?: show_label?
} = section_config,
opts
) do
polling_interval = polling_interval_seconds * 1000
ical_urls =
[
section_config.ical_url_1,
section_config.ical_url_2,
section_config.ical_url_3,
section_config.ical_url_4,
section_config.ical_url_5
]
|> Enum.filter(fn url ->
is_binary(url) and url |> String.trim() |> String.length() > 0
end)
System.subscribe("timezone")
System.subscribe("time_format")
scene =
scene
|> assign(
polling_interval: polling_interval,
ical_urls: ical_urls,
max_displayed_events: max_displayed_events,
timezone: System.timezone(),
time_format: System.time_format(),
calendar_name: calendar_name,
show_label?: show_label?,
layout_align: opts[:layout_align]
)
graph = refresh(scene.assigns)
schedule_poll(polling_interval)
{:ok, push_section(scene, graph)}
end
defp schedule_poll(interval) do
Process.send_after(self(), :poll_feed, interval)
end
@impl true
def handle_info(:poll_feed, %Scene{assigns: %{polling_interval: polling_interval}} = scene) do
graph = refresh(scene.assigns)
schedule_poll(polling_interval)
{:noreply, push_section(scene, graph)}
end
def handle_info(
%PropertyTable.Event{
property: ["system", "time_format"],
value: time_format
},
scene
) do
scene =
scene
|> assign(time_format: time_format)
graph = refresh(scene.assigns)
{:noreply, push_section(scene, graph)}
end
def handle_info(
%PropertyTable.Event{
property: ["system", "timezone"],
value: timezone
},
scene
) do
scene =
scene
|> assign(timezone: timezone)
graph = refresh(scene.assigns)
{:noreply, push_section(scene, graph)}
end
@spec refresh(%{
:calendar_name => nil | binary(),
:ical_urls => list({binary(), binary()}),
:max_displayed_events => integer(),
:timezone => binary(),
:time_format => binary(),
optional(any()) => any()
}) :: Scenic.Graph.t()
def refresh(
%{
ical_urls: ical_urls,
timezone: timezone,
max_displayed_events: max_displayed_events
} = assigns
) do
Service.retrieve_calendar_events(ical_urls, timezone)
|> Enum.take(max_displayed_events)
|> render(assigns)
end
def render(events, %{
timezone: timezone,
calendar_name: calendar_name,
show_label?: show_label?,
time_format: time_format,
layout_align: layout_align
}) do
Graph.build(text_align: :left)
|> render_events(events, timezone, time_format)
|> render_section_label(%{label: calendar_name, show_label?: show_label?},
align: layout_align
)
end
defp render_events(graph, events, timezone, time_format) do
grouped_events =
events
|> Enum.group_by(fn %{dtstart: dtstart} ->
dtstart
|> DateTime.to_date()
end)
grouped_events
|> Map.keys()
|> Enum.sort(Date)
|> Enum.reduce(graph, fn date, graph ->
events = grouped_events[date]
graph =
graph
|> render_date(date, timezone)
|> render_events_for_date(events, time_format)
v_offset = get_bottom_bound(graph) + 4
graph
|> line({{0, v_offset}, {@total_width, v_offset}}, stroke: {1, :white})
end)
end
defp render_date(graph, date, timezone) do
styles =
[]
|> h7()
|> bold()
font_size = Keyword.get(styles, :font_size)
font = Keyword.get(styles, :font, :roboto)
day_of_week = format_day_of_week(date, timezone)
date_formatted = Calendar.strftime(date, "%b %-d")
v_offset = get_bottom_bound(graph) + font_size + 4
{:ok, {Static.Font, fm}} = Static.meta(font)
day_of_week_width = FontMetrics.width(day_of_week, font_size, fm)
graph
|> text(day_of_week, styles ++ [t: {0, v_offset}])
|> text(
date_formatted,
(h7() |> light()) ++ [t: {day_of_week_width + 4, v_offset}]
)
end
defp render_events_for_date(graph, events, time_format) do
{:ok, {Static.Font, fm}} = Static.meta(@summary_style.font)
events
|> Enum.reduce(graph, fn event, graph ->
v_offset = get_bottom_bound(graph)
formatted_time = format_event_time(event, time_format)
summary =
FontMetrics.wrap(event.summary, @summary_width, @summary_style.font_size, fm)
graph
|> text(formatted_time,
font_size: @summary_style.font_size - 4,
t: {0, v_offset + @summary_style.font_size}
)
|> text(summary,
font: @summary_style.font,
font_size: @summary_style.font_size,
t: {@time_width, v_offset + @summary_style.font_size},
id: :event
)
end)
end
defp get_bottom_bound(graph) do
case Graph.bounds(graph) do
nil -> 0
bounds -> elem(bounds, 3)
end
end
defp format_day_of_week(%Date{} = date, timezone) do
today =
DateTime.now!(timezone)
|> DateTime.to_date()
cond do
date == today -> "Today"
date == Date.add(today, 1) -> "Tomorrow"
true -> Calendar.strftime(date, "%A")
end
end
defp format_event_time(%Event{dtstart: start_time} = event, time_format) do
cond do
Service.all_day_event?(event) ->
"Allday"
true ->
Calendar.strftime(start_time, time_format)
end
end
end