defmodule ReflectOS.Core.Sections.Countdown do use ReflectOS.Kernel.Section, has_children: false alias Scenic.Graph alias Scenic.Assets.Static import Scenic.Primitives, only: [{:text, 3}] import Phoenix.Component, only: [sigil_H: 2] import ReflectOS.Kernel.Typography alias ReflectOS.Kernel.Settings.System alias ReflectOS.Kernel.Section.Definition alias ReflectOS.Kernel.{OptionGroup, Option} import ReflectOS.Kernel.Primitives, only: [render_section_label: 3] embedded_schema do field(:show_label?, :boolean, default: false) field(:label, :string) field(:countdown_datetime, :naive_datetime) field(:description, :string) end @impl true def changeset(%__MODULE__{} = section, params \\ %{}) do section |> cast(params, [:show_label?, :label, :countdown_datetime, :description]) |> validate_required([:countdown_datetime]) end @doc false @impl true def section_definition(), do: %Definition{ name: "Countdown", icon: """ """, description: fn assigns -> ~H""" This section allows you to configure a date and time, and show a countdown on your ReflectOS dashboard. """ end } @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 } ] }, # Countdown DateTime %Option{ key: :countdown_datetime, label: "Countdown Until", config: %{ type: "datetime-local" } }, # Descrition %Option{ key: :description, label: "Description", config: %{ type: "textarea", placeholder: "Until the next release of ReflectOS!" } } ] @doc false @impl true def init_section(scene, %__MODULE__{} = section_config, opts) do scene = scene |> assign( layout_align: opts[:layout_align], show_label?: section_config.show_label?, label: section_config.label, countdown_datetime: DateTime.from_naive!(section_config.countdown_datetime, System.timezone()), description: section_config.description, last: nil ) |> render() # Get notified if the system timezone changes System.subscribe("timezone") # send a message to self to start the clock a fraction of a second # into the future to hopefully line it up closer to when the seconds # actually are. Note that I want it to arrive just slightly after # the one second mark, which is way better than just slighty before. # avoid trunc errors and such that way even if it means the second # timer is one millisecond behind the actual time. {microseconds, _} = Time.utc_now().microsecond Process.send_after(self(), :start_clock, 1001 - trunc(microseconds / 1000)) {:ok, scene} end # -------------------------------------------------------- @doc false # should be shortly after the actual one-second mark @impl GenServer def handle_info(:start_clock, scene) do # start the timer on a one-second interval {:ok, timer} = :timer.send_interval(1000, :tick_tock) scene = scene |> assign(:timer, timer) |> render() # update the clock {:noreply, scene} end # -------------------------------------------------------- def handle_info(:tick_tock, scene) do {:noreply, render(scene)} end def handle_info( %PropertyTable.Event{ property: ["system", "timezone"], value: timezone }, scene ) do countdown_datetime = get(scene, :countdown_datetime) scene = scene |> assign(:countdown_datetime, DateTime.shift_zone(countdown_datetime, timezone)) {:noreply, render(scene)} end # -------------------------------------------------------- defp render( %Scenic.Scene{ assigns: %{ countdown_datetime: countdown_datetime, last: last } } = scene ) do date_time = DateTime.now!(countdown_datetime.time_zone) |> DateTime.truncate(:second) if date_time != last do graph = render_graph(scene.assigns) scene |> assign(last: date_time) |> push_section(graph) else scene end end defp render_graph( %{ layout_align: layout_align, show_label?: show_label?, label: label, countdown_datetime: countdown_datetime, description: description } = assigns ) do diff = DateTime.diff(countdown_datetime, DateTime.now!(countdown_datetime.time_zone)) diff = if diff < 0 do # We hit the target date, let's cancel the timer if assigns[:timer] != nil, do: :timer.cancel(assigns[:timer]) # Just show zeroes instead of negative numbers if we're past the countdown date/time 0 else diff end # TODO - account for leap years {years, rem} = divmod(diff, 60 * 60 * 24 * 365) {days, rem} = divmod(rem, 60 * 60 * 24) {hours, rem} = divmod(rem, 60 * 60) {minutes, seconds} = divmod(rem, 60) {:ok, {Static.Font, fm}} = Static.meta(:roboto) [{part1_count, part1_label}, {part2_count, part2_label}, {part3_count, part3_label}] = cond do years >= 1 -> [{years, "years"}, {days, "days"}, {hours, "hours"}] days >= 1 -> [{days, "days"}, {hours, "hours"}, {minutes, "mins"}] true -> [{hours, "hours"}, {minutes, "mins"}, {seconds, "secs"}] end label_y = 72 space_width = FontMetrics.width(" ", 72, fm) # Part 1 padded_count = "#{String.pad_leading("#{part1_count}", 2, "0")}" count_width = FontMetrics.width(padded_count, 72, fm) label_x = 0 - count_width + space_width graph = Graph.build() |> text( "#{padded_count} :", [id: :part1_count, text_base: :top, text_align: :right] |> h2() |> bold() ) |> text( part1_label, [id: :part1_label, text_base: :top, text_align: :center, t: {label_x, label_y}] |> h7() |> light() ) # Part 2 {_left, _top, right, _bottom} = Graph.bounds(graph) right = right + space_width padded_count = "#{String.pad_leading("#{part2_count}", 2, "0")}" count_width = FontMetrics.width(padded_count, 72, fm) label_x = right + count_width / 2 graph = graph |> text( "#{padded_count} : ", [id: :part2_count, text_base: :top, text_align: :left, t: {right, 0}] |> h2() |> bold() ) |> text( part2_label, [id: :part2_label, text_base: :top, text_align: :center, t: {label_x, label_y}] |> h7() |> light() ) # Part 3 {_left, _top, right, _bottom} = Graph.bounds(graph) padded_count = "#{String.pad_leading("#{part3_count}", 2, "0")}" count_width = FontMetrics.width(padded_count, 72, fm) label_x = right + count_width / 2 graph = graph |> text( padded_count, [id: :part2_count, text_base: :top, text_align: :left, t: {right, 0}] |> h2() |> bold() ) |> text( part3_label, [id: :part2_label, text_base: :top, text_align: :center, t: {label_x, label_y}] |> h7() |> light() ) graph = if is_binary(description) do {left, _top, right, bottom} = Graph.bounds(graph) width = right - left wrapped = FontMetrics.wrap(description, width, 32, fm) graph |> text( wrapped, [ id: :description, text_base: :top, text_align: :center, t: {(right + left) / 2, bottom + 12} ] |> h7() ) else graph end graph |> render_section_label(%{show_label?: show_label?, label: label}, align: layout_align ) end defp divmod(e, d) do ~w|div rem|a |> Enum.map(&apply(Kernel, &1, [e, d])) |> List.to_tuple() end end