🏡 index : quine-global-labs/bazaar.git

/* bz-full-view.c
 *
 * Copyright 2025 Adam Masciola
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

#define G_LOG_DOMAIN "BAZAAR::FULL-VIEW-WIDGET"

#include <bzvala.h>
#include <glib/gi18n.h>
#include <json-glib/json-glib.h>

#include "bz-addon-tile.h"
#include "bz-addons-dialog.h"
#include "bz-age-rating-dialog.h"
#include "bz-app-size-dialog.h"
#include "bz-app-tile.h"
#include "bz-apps-page.h"
#include "bz-appstream-description-render.h"
#include "bz-context-tile.h"
#include "bz-developer-badge.h"
#include "bz-dynamic-list-view.h"
#include "bz-entry-inspector.h"
#include "bz-fading-clamp.h"
#include "bz-favorite-button.h"
#include "bz-flatpak-entry.h"
#include "bz-full-view.h"
#include "bz-hardware-support-dialog.h"
#include "bz-install-controls.h"
#include "bz-license-dialog.h"
#include "bz-metainfo-preview.h"
#include "bz-releases-list.h"
#include "bz-safety-dialog.h"
#include "bz-screenshot-page.h"
#include "bz-screenshots-carousel.h"
#include "bz-section-view.h"
#include "bz-share-list.h"
#include "bz-state-info.h"
#include "bz-stats-dialog.h"
#include "bz-window.h"
#include "context-tile-callbacks.h"
#include "error.h"
#include "safety-calculator.h"
#include "spdx.h"
#include "template-callbacks.h"
#include "util.h"

struct _BzFullView
{
  AdwBin parent_instance;

  BzStateInfo          *state;
  BzTransactionManager *transactions;
  BzEntryGroup         *group;
  DexFuture            *ui_future;
  BzResult             *ui_entry;
  BzResult             *runtime;
  BzResult             *group_model;
  gboolean              show_sidebar;

  /* Template widgets */
  GtkScrolledWindow *main_scroll;
  AdwViewStack      *stack;
  GtkWidget         *shadow_overlay;
  GtkToggleButton   *description_toggle;
  GtkWidget         *wide_install_controls;
  GtkWidget         *narrow_install_controls;
};

G_DEFINE_FINAL_TYPE (BzFullView, bz_full_view, ADW_TYPE_BIN)

enum
{
  PROP_0,

  PROP_STATE,
  PROP_ENTRY_GROUP,
  PROP_UI_ENTRY,

  LAST_PROP
};
static GParamSpec *props[LAST_PROP] = { 0 };

enum
{
  SIGNAL_UPDATE,

  LAST_SIGNAL,
};
static guint signals[LAST_SIGNAL];

static void
bz_full_view_dispose (GObject *object)
{
  BzFullView *self = BZ_FULL_VIEW (object);

  dex_clear (&self->ui_future);
  g_clear_object (&self->state);
  g_clear_object (&self->transactions);
  g_clear_object (&self->group);
  g_clear_object (&self->ui_entry);
  g_clear_object (&self->runtime);
  g_clear_object (&self->group_model);

  G_OBJECT_CLASS (bz_full_view_parent_class)->dispose (object);
}

static void
bz_full_view_get_property (GObject    *object,
                           guint       prop_id,
                           GValue     *value,
                           GParamSpec *pspec)
{
  BzFullView *self = BZ_FULL_VIEW (object);

  switch (prop_id)
    {
    case PROP_STATE:
      g_value_set_object (value, self->state);
      break;
    case PROP_ENTRY_GROUP:
      g_value_set_object (value, bz_full_view_get_entry_group (self));
      break;
    case PROP_UI_ENTRY:
      g_value_set_object (value, self->ui_entry);
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_full_view_set_property (GObject      *object,
                           guint         prop_id,
                           const GValue *value,
                           GParamSpec   *pspec)
{
  BzFullView *self = BZ_FULL_VIEW (object);

  switch (prop_id)
    {
    case PROP_STATE:
      g_clear_object (&self->state);
      self->state = g_value_dup_object (value);
      break;
    case PROP_ENTRY_GROUP:
      bz_full_view_set_entry_group (self, g_value_get_object (value));
      break;
    case PROP_UI_ENTRY:
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static gboolean
is_scrolled_down (gpointer object,
                  double   value)
{
  return value > 100.0;
}

static char *
format_as_link (gpointer    object,
                const char *value)
{
  if (value != NULL)
    return g_strdup_printf ("<a href=\"%s\" title=\"%s\">%s</a>",
                            value, value, value);
  else
    return g_strdup (_ ("No URL"));
}

static gboolean
has_link (gpointer    object,
          const char *license)
{
  if (license == NULL || *license == '\0')
    return FALSE;

  return bz_spdx_is_valid (license);
}

static char *
pick_license_warning (gpointer object,
                      gboolean value)
{
  return value
             ? g_strdup (_ ("This app has a FLOSS license, meaning the source code can be audited for safety."))
             : g_strdup (_ ("This app has a proprietary license, meaning the source code is developed privately and cannot be audited by an independent third party."));
}

static char *
format_other_apps_label (gpointer object, const char *developer)
{
  if (!developer || *developer == '\0')
    return g_strdup (_ ("Other Apps From This Developer"));
  return g_strdup_printf (_ ("Other Apps From %s"), developer);
}

static char *
format_more_other_apps_label (gpointer object, const char *developer)
{
  return g_strdup (_ ("Show All"));
}

static char *
format_leftover_label (gpointer object, const char *name, guint64 size)
{
  g_autofree char *formatted_size = NULL;

  formatted_size = g_format_size (size);
  return g_strdup_printf (_ ("%s is not installed, but it still has <b>%s</b> of data present."), name, formatted_size);
}

static gpointer
filter_own_app_id (BzEntry *entry, GtkStringList *app_ids)
{
  const char *own_id;
  g_autoptr (GtkStringList) filtered = NULL;
  guint n_items                      = 0;

  if (!BZ_IS_ENTRY (entry) || !GTK_IS_STRING_LIST (app_ids))
    return NULL;

  own_id = bz_entry_get_id (entry);
  if (!own_id)
    return NULL;

  filtered = gtk_string_list_new (NULL);
  n_items  = g_list_model_get_n_items (G_LIST_MODEL (app_ids));

  for (guint i = 0; i < n_items; i++)
    {
      const char *id = NULL;

      id = gtk_string_list_get_string (app_ids, i);
      if (g_strcmp0 (id, own_id) != 0)
        gtk_string_list_append (filtered, id);
    }

  if (g_list_model_get_n_items (G_LIST_MODEL (filtered)) > 0)
    return g_steal_pointer (&filtered);
  else
    return NULL;
}

static GListModel *
get_developer_apps_entries (gpointer object, GtkStringList *app_ids, BzEntry *entry)
{
  BzFullView *self                   = BZ_FULL_VIEW (object);
  g_autoptr (GtkStringList) filtered = filter_own_app_id (BZ_ENTRY (entry), app_ids);
  BzApplicationMapFactory *factory;

  if (!filtered)
    return NULL;

  factory = bz_state_info_get_application_factory (self->state);
  if (!factory)
    return NULL;

  return bz_application_map_factory_generate (factory, G_LIST_MODEL (filtered));
}

static void
more_apps_button_clicked_cb (BzFullView *self,
                             GtkButton  *button)
{
  g_autoptr (GListModel) model = NULL;
  guint              n_items;
  g_autofree char   *title       = NULL;
  g_autofree char   *subtitle    = NULL;
  AdwNavigationPage *apps_page   = NULL;
  GtkWidget         *nav_view    = NULL;
  g_autoptr (GListModel) app_ids = NULL;
  BzEntry    *entry              = NULL;
  const char *developer          = NULL;

  g_return_if_fail (BZ_IS_FULL_VIEW (self));
  g_return_if_fail (GTK_IS_BUTTON (button));

  entry = bz_result_get_object (self->ui_entry);
  if (entry == NULL)
    return;

  g_object_get (entry, "developer-apps", &app_ids, NULL);

  model = bz_application_map_factory_generate (
      bz_state_info_get_application_factory (self->state),
      app_ids);

  n_items = g_list_model_get_n_items (model);

  developer = bz_entry_get_developer (entry);
  if (developer != NULL && *developer != '\0')
    title = g_strdup_printf (_ ("Apps by %s"), developer);
  else
    title = g_strdup (_ ("Apps by This Developer"));

  subtitle = g_strdup_printf (ngettext ("%d Application", "%d Applications", n_items), n_items);

  apps_page = bz_apps_page_new (title, model);
  bz_apps_page_set_subtitle (BZ_APPS_PAGE (apps_page), subtitle);

  nav_view = gtk_widget_get_ancestor (GTK_WIDGET (self), ADW_TYPE_NAVIGATION_VIEW);
  if (nav_view != NULL)
    adw_navigation_view_push (ADW_NAVIGATION_VIEW (nav_view), apps_page);
}

static void
open_url_cb (BzFullView   *self,
             AdwActionRow *row)
{
  BzEntry    *entry = NULL;
  const char *url   = NULL;

  entry = BZ_ENTRY (bz_result_get_object (self->ui_entry));
  url   = bz_entry_get_url (entry);

  if (url != NULL && *url != '\0')
    gtk_widget_activate_action (GTK_WIDGET (row), "window.open-uri", "s", url);
}

static void
license_cb (BzFullView *self,
            GtkButton  *button)
{
  AdwDialog *dialog   = NULL;
  BzEntry   *ui_entry = NULL;

  if (self->group == NULL)
    return;

  ui_entry = bz_result_get_object (self->ui_entry);
  if (ui_entry == NULL)
    return;

  dialog = bz_license_dialog_new (ui_entry);
  adw_dialog_present (dialog, GTK_WIDGET (self));
}

static void
age_rating_cb (BzFullView *self,
               GtkButton  *button)
{
  BzAgeRatingDialog *dialog   = NULL;
  BzEntry           *ui_entry = NULL;

  if (self->group == NULL)
    return;

  ui_entry = bz_result_get_object (self->ui_entry);
  if (ui_entry == NULL)
    return;

  dialog = bz_age_rating_dialog_new (ui_entry);
  adw_dialog_present (ADW_DIALOG (dialog), GTK_WIDGET (self));
}

static void
dl_stats_cb (BzFullView *self,
             GtkButton  *button)
{
  AdwDialog        *dialog   = NULL;
  AdwBreakpointBin *bin      = NULL;
  BzEntry          *ui_entry = NULL;

  if (self->group == NULL)
    return;

  ui_entry = bz_result_get_object (self->ui_entry);

  bin    = bz_stats_dialog_new (NULL, NULL, 0);
  dialog = adw_dialog_new ();
  adw_dialog_set_content_width (dialog, 1250);
  adw_dialog_set_content_height (dialog, 750);
  adw_dialog_set_child (dialog, GTK_WIDGET (bin));

  g_object_bind_property (ui_entry, "download-stats", bin, "model", G_BINDING_SYNC_CREATE);
  g_object_bind_property (ui_entry, "download-stats-per-country", bin, "country-model", G_BINDING_SYNC_CREATE);
  g_object_bind_property (ui_entry, "total-downloads", bin, "total-downloads", G_BINDING_SYNC_CREATE);

  adw_dialog_present (dialog, GTK_WIDGET (self));
  bz_stats_dialog_animate_open (BZ_STATS_DIALOG (bin));
}

static void
size_cb (BzFullView *self,
         GtkButton  *button)
{
  AdwDialog *size_dialog = NULL;

  if (self->group == NULL)
    return;

  size_dialog = bz_app_size_dialog_new (self->group);
  adw_dialog_present (size_dialog, GTK_WIDGET (self));
}

static void
formfactor_cb (BzFullView *self,
               GtkButton  *button)
{
  AdwDialog *dialog   = NULL;
  BzEntry   *ui_entry = NULL;

  if (self->group == NULL)
    return;

  ui_entry = bz_result_get_object (self->ui_entry);
  dialog   = ADW_DIALOG (bz_hardware_support_dialog_new (ui_entry));

  adw_dialog_present (dialog, GTK_WIDGET (self));
}

static void
safety_cb (BzFullView *self,
           GtkButton  *button)
{
  AdwDialog *dialog   = NULL;
  BzEntry   *ui_entry = NULL;

  if (self->group == NULL)
    return;

  ui_entry = bz_result_get_object (self->ui_entry);
  if (ui_entry == NULL)
    return;

  dialog = bz_safety_dialog_new (ui_entry);
  adw_dialog_present (dialog, GTK_WIDGET (self));
}

static void
update_cb (BzFullView        *self,
           GListModel        *entries,
           BzInstallControls *controls)
{
  g_signal_emit (self, signals[SIGNAL_UPDATE], 0, entries);
}

static DexFuture *
reap_user_data_done (DexFuture *future,
                     GWeakRef  *wr)
{
  g_autoptr (BzFullView) self    = NULL;
  g_autoptr (GError) local_error = NULL;

  dex_future_get_value (future, &local_error);

  self = g_weak_ref_get (wr);
  if (self != NULL && local_error != NULL)
    bz_show_error_for_widget (
        GTK_WIDGET (gtk_widget_get_root (GTK_WIDGET (self))),
        _ ("Failed to Remove User Data"),
        local_error->message);

  return dex_future_new_true ();
}

static void
delete_user_data_cb (BzFullView *self,
                     GtkButton  *button)
{
  g_autoptr (DexFuture) future = NULL;

  g_return_if_fail (BZ_IS_FULL_VIEW (self));

  if (self->group == NULL)
    return;

  future = bz_entry_group_reap_user_data (self->group);
  if (future != NULL)
    dex_future_disown (dex_future_finally (
        dex_ref (future),
        (DexFutureCallback) reap_user_data_done,
        bz_track_weak (self),
        bz_weak_release));
}

static void
support_cb (BzFullView *self,
            GtkButton  *button)
{
  BzEntry    *entry = NULL;
  const char *url   = NULL;

  entry = bz_result_get_object (self->ui_entry);
  if (entry == NULL)
    return;

  url = bz_entry_get_donation_url (entry);
  if (url != NULL && *url != '\0')
    gtk_widget_activate_action (GTK_WIDGET (button), "window.open-uri", "s", url);
}

static GListModel *
get_addon_groups (BzFullView   *self,
                  BzEntryGroup *group)
{
  BzApplicationMapFactory *factory = NULL;
  GListModel              *ids     = NULL;
  GListModel              *groups  = NULL;

  if (group == NULL)
    return NULL;

  ids = bz_entry_group_get_addon_group_ids (group);
  if (ids == NULL)
    return NULL;

  factory = bz_state_info_get_application_factory (self->state);
  groups  = bz_application_map_factory_generate (factory, ids);
  if (groups == NULL)
    return NULL;

  return G_LIST_MODEL (gtk_slice_list_model_new (groups, 0, 3));
}

static gboolean
should_show_addon_overflow (gpointer      object,
                            BzEntryGroup *group)
{
  GListModel *ids = NULL;

  if (group == NULL)
    return FALSE;

  ids = bz_entry_group_get_addon_group_ids (group);
  if (ids == NULL)
    return FALSE;

  return g_list_model_get_n_items (ids) > 3;
}

static void
other_source_download_cb (BzFullView *self,
                          BzEntry    *entry)
{
  GtkWidget          *window    = NULL;
  GtkAdjustment      *adj       = NULL;
  AdwAnimationTarget *target    = NULL;
  AdwAnimation       *animation = NULL;

  adj       = gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->main_scroll));
  target    = adw_property_animation_target_new (G_OBJECT (adj), "value");
  animation = adw_timed_animation_new (
      GTK_WIDGET (self), gtk_adjustment_get_value (adj),
      0.0, 300, target);
  adw_timed_animation_set_easing (ADW_TIMED_ANIMATION (animation), ADW_EASE_OUT_CUBIC);
  adw_animation_play (animation);

  bz_install_controls_grab_focus (BZ_INSTALL_CONTROLS (self->narrow_install_controls));
  bz_install_controls_grab_focus (BZ_INSTALL_CONTROLS (self->wide_install_controls));

  window = GTK_WIDGET (gtk_widget_get_root (GTK_WIDGET (self)));
  if (BZ_IS_WINDOW (window))
    bz_window_install_entry (BZ_WINDOW (window), entry);
}

static void
install_addons_cb (BzFullView *self,
                   GtkButton  *button)
{
  if (self->group == NULL)
    return;

  gtk_widget_activate_action (GTK_WIDGET (self), "window.addons-group", "s",
                              bz_entry_group_get_id (self->group));
}

static void
addon_tile_activated_cb (BzAddonTile *tile)
{
  BzFullView   *self   = NULL;
  BzEntryGroup *group  = NULL;
  AdwDialog    *dialog = NULL;

  self  = BZ_FULL_VIEW (gtk_widget_get_ancestor (GTK_WIDGET (tile), BZ_TYPE_FULL_VIEW));
  group = bz_addon_tile_get_group (tile);

  if (group == NULL)
    return;

  dialog = bz_addons_dialog_new_single (group);
  adw_dialog_present (dialog, GTK_WIDGET (self));
}

static int
get_description_max_height (gpointer object,
                            gboolean active)
{
  return active ? 10000 : 170;
}

static char *
get_description_toggle_text (gpointer object,
                             gboolean active)
{
  return g_strdup (active ? _ ("Show Less") : _ ("Show More"));
}

static gboolean
metainfo_banner_visible (gpointer    object,
                         const char *remote_name)
{
  return g_strcmp0 (remote_name, "local-preview") == 0;
}

static void
preview_other_metainfo_cb (BzFullView *self,
                           AdwBanner  *banner)
{
  GtkWidget *window = NULL;

  window = GTK_WIDGET (gtk_widget_get_root (GTK_WIDGET (self)));
  bz_window_push_page (BZ_WINDOW (window),
                       create_entry_group_preview_page (self->group));
}

static void
copy_id_cb (BzFullView *self,
            GtkButton  *button)
{
  const char   *id        = NULL;
  GdkClipboard *clipboard = NULL;

  if (self->group == NULL)
    return;
  id = bz_entry_group_get_id (self->group);

  clipboard = gdk_display_get_clipboard (gdk_display_get_default ());
  gdk_clipboard_set_text (clipboard, id);
}

static void
debug_id_inspect_cb (BzFullView *self,
                     GtkButton  *button)
{
  g_autofree char *unique_id         = NULL;
  g_autoptr (GtkStringObject) string = NULL;
  g_autoptr (BzResult) result        = NULL;

  if (self->group == NULL)
    return;
  unique_id = bz_entry_group_dup_ui_entry_id (self->group);

  result = bz_application_map_factory_convert_one (
      bz_state_info_get_entry_factory (self->state),
      gtk_string_object_new (unique_id));
  if (result != NULL)
    {
      BzEntryInspector *inspector = NULL;

      inspector = bz_entry_inspector_new ();
      bz_entry_inspector_set_result (inspector, result);

      gtk_window_present (GTK_WINDOW (inspector));
    }
}

static void
bz_full_view_class_init (BzFullViewClass *klass)
{
  GObjectClass   *object_class = G_OBJECT_CLASS (klass);
  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);

  object_class->dispose      = bz_full_view_dispose;
  object_class->get_property = bz_full_view_get_property;
  object_class->set_property = bz_full_view_set_property;

  props[PROP_STATE] =
      g_param_spec_object (
          "state",
          NULL, NULL,
          BZ_TYPE_STATE_INFO,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);

  props[PROP_ENTRY_GROUP] =
      g_param_spec_object (
          "entry-group",
          NULL, NULL,
          BZ_TYPE_ENTRY_GROUP,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_UI_ENTRY] =
      g_param_spec_object (
          "ui-entry",
          NULL, NULL,
          BZ_TYPE_RESULT,
          G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  g_object_class_install_properties (object_class, LAST_PROP, props);

  signals[SIGNAL_UPDATE] =
      g_signal_new (
          "update",
          G_OBJECT_CLASS_TYPE (klass),
          G_SIGNAL_RUN_FIRST,
          0,
          NULL, NULL,
          g_cclosure_marshal_VOID__OBJECT,
          G_TYPE_NONE, 1,
          G_TYPE_LIST_MODEL);
  g_signal_set_va_marshaller (
      signals[SIGNAL_UPDATE],
      G_TYPE_FROM_CLASS (klass),
      g_cclosure_marshal_VOID__OBJECTv);

  g_type_ensure (BZ_TYPE_APPSTREAM_DESCRIPTION_RENDER);
  g_type_ensure (BZ_TYPE_DEVELOPER_BADGE);
  g_type_ensure (BZ_TYPE_DYNAMIC_LIST_VIEW);
  g_type_ensure (BZ_TYPE_ENTRY);
  g_type_ensure (BZ_TYPE_ENTRY_GROUP);
  g_type_ensure (BZ_TYPE_FADING_CLAMP);
  g_type_ensure (BZ_TYPE_FAVORITE_BUTTON);
  g_type_ensure (BZ_TYPE_FLATPAK_ENTRY);
  g_type_ensure (BZ_TYPE_HARDWARE_SUPPORT_DIALOG);
  g_type_ensure (BZ_TYPE_INSTALL_CONTROLS);
  g_type_ensure (BZ_TYPE_SECTION_VIEW);
  g_type_ensure (BZ_TYPE_RELEASES_PREVIEW);
  g_type_ensure (BZ_TYPE_SCREENSHOTS_CAROUSEL);
  g_type_ensure (BZ_TYPE_SHARE_LIST);
  g_type_ensure (BZ_TYPE_CONTEXT_TILE);
  g_type_ensure (BZ_TYPE_ADDON_TILE);
  g_type_ensure (BZ_TYPE_OTHER_SOURCES_LIST);

  gtk_widget_class_set_template_from_resource (widget_class, "/io/github/kolunmi/Bazaar/bz-full-view.ui");
  bz_widget_class_bind_all_util_callbacks (widget_class);
  bz_widget_class_bind_all_context_tile_callbacks (widget_class);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, stack);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, main_scroll);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, shadow_overlay);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, description_toggle);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, wide_install_controls);
  gtk_widget_class_bind_template_child (widget_class, BzFullView, narrow_install_controls);
  gtk_widget_class_bind_template_callback (widget_class, is_scrolled_down);
  gtk_widget_class_bind_template_callback (widget_class, age_rating_cb);
  gtk_widget_class_bind_template_callback (widget_class, format_as_link);
  gtk_widget_class_bind_template_callback (widget_class, has_link);
  gtk_widget_class_bind_template_callback (widget_class, format_leftover_label);
  gtk_widget_class_bind_template_callback (widget_class, format_other_apps_label);
  gtk_widget_class_bind_template_callback (widget_class, format_more_other_apps_label);
  gtk_widget_class_bind_template_callback (widget_class, get_developer_apps_entries);
  gtk_widget_class_bind_template_callback (widget_class, more_apps_button_clicked_cb);
  gtk_widget_class_bind_template_callback (widget_class, open_url_cb);
  gtk_widget_class_bind_template_callback (widget_class, license_cb);
  gtk_widget_class_bind_template_callback (widget_class, dl_stats_cb);
  gtk_widget_class_bind_template_callback (widget_class, size_cb);
  gtk_widget_class_bind_template_callback (widget_class, formfactor_cb);
  gtk_widget_class_bind_template_callback (widget_class, safety_cb);
  gtk_widget_class_bind_template_callback (widget_class, update_cb);
  gtk_widget_class_bind_template_callback (widget_class, delete_user_data_cb);
  gtk_widget_class_bind_template_callback (widget_class, support_cb);
  gtk_widget_class_bind_template_callback (widget_class, pick_license_warning);
  gtk_widget_class_bind_template_callback (widget_class, get_addon_groups);
  gtk_widget_class_bind_template_callback (widget_class, should_show_addon_overflow);
  gtk_widget_class_bind_template_callback (widget_class, install_addons_cb);
  gtk_widget_class_bind_template_callback (widget_class, other_source_download_cb);
  gtk_widget_class_bind_template_callback (widget_class, addon_tile_activated_cb);
  gtk_widget_class_bind_template_callback (widget_class, get_description_max_height);
  gtk_widget_class_bind_template_callback (widget_class, get_description_toggle_text);
  gtk_widget_class_bind_template_callback (widget_class, metainfo_banner_visible);
  gtk_widget_class_bind_template_callback (widget_class, preview_other_metainfo_cb);
  gtk_widget_class_bind_template_callback (widget_class, copy_id_cb);
  gtk_widget_class_bind_template_callback (widget_class, debug_id_inspect_cb);
}

static void
bz_full_view_init (BzFullView *self)
{
  gtk_widget_init_template (GTK_WIDGET (self));
}

GtkWidget *
bz_full_view_new (void)
{
  return g_object_new (BZ_TYPE_FULL_VIEW, NULL);
}

static DexFuture *
on_ui_entry_resolved (DexFuture *future,
                      GWeakRef  *wr)
{
  g_autoptr (BzFullView) self         = NULL;
  BzEntry *ui_entry                   = NULL;
  g_autoptr (BzResult) runtime_result = NULL;
  const GValue *value                 = NULL;

  bz_weak_get_or_return_reject (self, wr);

  value = dex_future_get_value (future, NULL);
  if (value != NULL && G_VALUE_HOLDS_OBJECT (value))
    {
      ui_entry = g_value_get_object (value);

      if (BZ_IS_FLATPAK_ENTRY (ui_entry))
        self->runtime = bz_flatpak_entry_dup_runtime_result (BZ_FLATPAK_ENTRY (ui_entry));
    }

  adw_view_stack_set_visible_child_name (self->stack, "content");

  return dex_future_new_for_boolean (TRUE);
}

static gboolean
idle_focus_install (GWeakRef *wr)
{
  g_autoptr (BzFullView) self = NULL;

  self = g_weak_ref_get (wr);
  if (self == NULL)
    return G_SOURCE_REMOVE;

  bz_install_controls_grab_focus (BZ_INSTALL_CONTROLS (self->narrow_install_controls));
  bz_install_controls_grab_focus (BZ_INSTALL_CONTROLS (self->wide_install_controls));

  return G_SOURCE_REMOVE;
}

void
bz_full_view_set_entry_group (BzFullView   *self,
                              BzEntryGroup *group)
{
  g_return_if_fail (BZ_IS_FULL_VIEW (self));
  g_return_if_fail (group == NULL ||
                    BZ_IS_ENTRY_GROUP (group));

  if (group == self->group)
    return;

  if (group == NULL)
    return;

  dex_clear (&self->ui_future);
  g_clear_object (&self->group);
  g_clear_object (&self->ui_entry);
  g_clear_object (&self->runtime);
  g_clear_object (&self->group_model);
  gtk_toggle_button_set_active (self->description_toggle, FALSE);

  self->group    = g_object_ref (group);
  self->ui_entry = bz_entry_group_dup_ui_entry (group);

  if (self->ui_entry != NULL && bz_result_get_resolved (self->ui_entry))
    {
      BzEntry *entry                      = NULL;
      g_autoptr (GListStore) store        = NULL;
      g_autoptr (DexFuture) future        = NULL;
      g_autoptr (DexFuture) object_future = NULL;
      GWeakRef *wr                        = NULL;

      entry = bz_result_get_object (self->ui_entry);
      store = g_list_store_new (BZ_TYPE_ENTRY);
      g_list_store_append (store, entry);

      future            = dex_future_new_for_object (store);
      self->group_model = bz_result_new (future);

      object_future = dex_future_new_for_object (entry);
      wr            = bz_track_weak (self);
      dex_unref (on_ui_entry_resolved (object_future, wr));
      bz_weak_release (wr);
    }
  else
    {
      g_autoptr (DexFuture) future = NULL;

      future            = bz_entry_group_dup_all_into_store (group);
      self->group_model = bz_result_new (future);

      if (self->ui_entry != NULL)
        {
          g_autoptr (DexFuture) ui_future = NULL;

          adw_view_stack_set_visible_child_name (self->stack, "loading");

          ui_future = bz_result_dup_future (self->ui_entry);
          ui_future = dex_future_then (
              ui_future,
              (DexFutureCallback) on_ui_entry_resolved,
              bz_track_weak (self),
              bz_weak_release);
          self->ui_future = g_steal_pointer (&ui_future);
        }
    }

  gtk_adjustment_set_value (gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->main_scroll)), 0.0);

  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_ENTRY_GROUP]);
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_UI_ENTRY]);

  g_idle_add_full (G_PRIORITY_DEFAULT_IDLE, (GSourceFunc) idle_focus_install, bz_track_weak (self), bz_weak_release);
}

BzEntryGroup *
bz_full_view_get_entry_group (BzFullView *self)
{
  g_return_val_if_fail (BZ_IS_FULL_VIEW (self), NULL);
  return self->group;
}