// SPDX-License-Identifier: GPL-3.0-or-later /* * $Id: gui.c,v 1.1 2026/05/08 03:23:59 asm Exp $ * * gui.c - GTK 3 user interface for GNUtrition * * Copyright (C) 2026 Free Software Foundation, Inc. * * Author: Jason Self * Anton McClure */ #ifdef HAVE_CONFIG_H #include #endif #include "gui.h" #include "budget.h" #include "db.h" #include "dbus.h" #include "log.h" #include "i18n.h" #include #include #include #include #include #define PROGRAM_NAME "gnutrition" /* G_APPLICATION_DEFAULT_FLAGS was added in GLib 2.74. */ #if !GLIB_CHECK_VERSION(2, 74, 0) #define G_APPLICATION_DEFAULT_FLAGS G_APPLICATION_FLAGS_NONE #endif /* Application state shared across callbacks. */ struct gui_state { sqlite3 *food_db; sqlite3 *log_db; int calories; /* Main window widgets. */ GtkWidget *window; GtkWidget *dashboard_box; GtkWidget *log_list_box; GtkWidget *budget_label; GtkWidget *log_frame; /* Currently displayed log date (ISO 8601). */ char log_date[11]; /* Progress bars for budget dashboard. */ GtkWidget *pb_vegetables; GtkWidget *pb_fruits; GtkWidget *pb_grains; GtkWidget *pb_dairy; GtkWidget *pb_protein; GtkWidget *pb_oils; /* D-Bus service. */ struct dbus_context dbus_ctx; guint dbus_owner_id; }; /* Return today's date as YYYY-MM-DD in a static buffer. */ static const char * today_date (void) { static char buf[11]; time_t now; struct tm *tm; now = time (NULL); tm = localtime (&now); strftime (buf, sizeof buf, "%Y-%m-%d", tm); return buf; } /* Format an ISO 8601 date (YYYY-MM-DD) for display using the locale's preferred date representation. Returns a pointer to a static buffer; not reentrant. */ static const char * format_date (const char *iso_date) { static char buf[64]; struct tm tm; memset (&tm, 0, sizeof tm); if (sscanf (iso_date, "%d-%d-%d", &tm.tm_year, &tm.tm_mon, &tm.tm_mday) != 3) return iso_date; tm.tm_year -= 1900; tm.tm_mon -= 1; if (strftime (buf, sizeof buf, "%x", &tm) == 0) return iso_date; return buf; } /* Update a single progress bar for a budget category. */ static void update_progress_bar (GtkWidget *pb, double budget, double consumed, const char *label) { double fraction; char text[128]; if (budget > 0.0) fraction = consumed / budget; else fraction = 0.0; if (fraction > 1.0) fraction = 1.0; gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (pb), fraction); snprintf (text, sizeof text, "%s: %.1f / %.1f", label, consumed, budget); gtk_progress_bar_set_text (GTK_PROGRESS_BAR (pb), text); gtk_progress_bar_set_show_text (GTK_PROGRESS_BAR (pb), TRUE); } /* Refresh the budget dashboard progress bars for the currently displayed log date. */ static void refresh_dashboard (struct gui_state *state) { struct daily_budget budget; struct daily_budget consumed; struct log_list entries; const char *date; budget = budget_for_calories (state->calories); memset (&consumed, 0, sizeof consumed); date = state->log_date; if (log_get_day (state->log_db, date, &entries) == 0) { size_t i; for (i = 0; i < entries.count; i++) { struct fped_entry fped; if (db_get_fped (state->food_db, entries.items[i].food_code, &fped) == 0) { double s = entries.items[i].servings; consumed.vegetables += fped.vegetables * s; consumed.fruits += fped.fruits * s; consumed.grains += fped.grains * s; consumed.dairy += fped.dairy * s; consumed.protein += fped.protein * s; consumed.oils += fped.oils * s; } } log_list_free (&entries); } update_progress_bar (state->pb_vegetables, budget.vegetables, consumed.vegetables, _("Vegetables (cup-eq)")); update_progress_bar (state->pb_fruits, budget.fruits, consumed.fruits, _("Fruits (cup-eq)")); update_progress_bar (state->pb_grains, budget.grains, consumed.grains, _("Grains (oz-eq)")); update_progress_bar (state->pb_dairy, budget.dairy, consumed.dairy, _("Dairy (cup-eq)")); update_progress_bar (state->pb_protein, budget.protein, consumed.protein, _("Protein (oz-eq)")); update_progress_bar (state->pb_oils, budget.oils, consumed.oils, _("Oils (g)")); /* Update the budget label to reflect the current calorie target and the date being displayed. */ if (state->budget_label) { char budget_text[192]; snprintf (budget_text, sizeof budget_text, _("USDA Healthy US-Style Eating Pattern (%d kcal) - %s"), state->calories, format_date (state->log_date)); gtk_label_set_text (GTK_LABEL (state->budget_label), budget_text); } } /* Forward declarations for mutual recursion. */ static void refresh_log_list (struct gui_state *state); static void refresh_all (struct gui_state *state); /* Callback for the "Delete" button on a log entry row. */ static void on_log_delete_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; int entry_id; GtkWidget *confirm; int response; (void) button; entry_id = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (button), "entry-id")); confirm = gtk_message_dialog_new (GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_QUESTION, GTK_BUTTONS_YES_NO, _("Delete this log entry?")); gtk_window_set_title (GTK_WINDOW (confirm), _("Confirm Delete")); response = gtk_dialog_run (GTK_DIALOG (confirm)); gtk_widget_destroy (confirm); if (response == GTK_RESPONSE_YES) { log_delete (state->log_db, entry_id); refresh_all (state); } } /* Callback for the Edit dialog "Save" button. */ static void on_edit_save_clicked (GtkDialog *dialog, gint response_id, gpointer user_data) { struct gui_state *state = user_data; if (response_id == GTK_RESPONSE_OK) { GtkWidget *spin; GtkWidget *calendar; int entry_id; double quantity; char date[11]; guint year, month, day; spin = g_object_get_data (G_OBJECT (dialog), "spin-quantity"); calendar = g_object_get_data (G_OBJECT (dialog), "calendar"); entry_id = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (dialog), "entry-id")); quantity = gtk_spin_button_get_value (GTK_SPIN_BUTTON (spin)); gtk_calendar_get_date (GTK_CALENDAR (calendar), &year, &month, &day); snprintf (date, sizeof date, "%04u-%02u-%02u", year, month + 1, day); log_update (state->log_db, entry_id, date, quantity); refresh_all (state); } gtk_widget_destroy (GTK_WIDGET (dialog)); } /* Callback for the "Edit" button on a log entry row. */ static void on_log_edit_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; int entry_id; double servings; const char *entry_date; const char *description; GtkWidget *dialog; GtkWidget *content; GtkWidget *spin; GtkWidget *calendar; GtkWidget *lbl; struct tm tm; entry_id = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (button), "entry-id")); { double *srv_ptr = g_object_get_data (G_OBJECT (button), "entry-servings"); servings = srv_ptr ? *srv_ptr : 1.0; } entry_date = g_object_get_data (G_OBJECT (button), "entry-date"); description = g_object_get_data (G_OBJECT (button), "entry-desc"); dialog = gtk_dialog_new_with_buttons ( description ? description : _("Edit Log Entry"), GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, _("Save"), GTK_RESPONSE_OK, _("Cancel"), GTK_RESPONSE_CANCEL, NULL); gtk_window_set_default_size (GTK_WINDOW (dialog), 400, 350); content = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); /* Quantity spinner. */ { GtkWidget *hbox = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); lbl = gtk_label_new (_("Servings:")); spin = gtk_spin_button_new_with_range (0.1, 100.0, 0.5); gtk_spin_button_set_value (GTK_SPIN_BUTTON (spin), servings); gtk_box_pack_start (GTK_BOX (hbox), lbl, FALSE, FALSE, 6); gtk_box_pack_start (GTK_BOX (hbox), spin, FALSE, FALSE, 6); gtk_box_pack_start (GTK_BOX (content), hbox, FALSE, FALSE, 6); } /* Date selector. */ { GtkWidget *date_frame = gtk_frame_new (_("Date")); calendar = gtk_calendar_new (); /* Set the calendar to the entry's date. */ memset (&tm, 0, sizeof tm); if (entry_date && sscanf (entry_date, "%d-%d-%d", &tm.tm_year, &tm.tm_mon, &tm.tm_mday) == 3) { gtk_calendar_select_month (GTK_CALENDAR (calendar), (guint) (tm.tm_mon - 1), (guint) tm.tm_year); gtk_calendar_select_day (GTK_CALENDAR (calendar), (guint) tm.tm_mday); } gtk_container_add (GTK_CONTAINER (date_frame), calendar); gtk_box_pack_start (GTK_BOX (content), date_frame, FALSE, FALSE, 6); } g_object_set_data (G_OBJECT (dialog), "spin-quantity", spin); g_object_set_data (G_OBJECT (dialog), "calendar", calendar); g_object_set_data (G_OBJECT (dialog), "entry-id", GINT_TO_POINTER (entry_id)); g_signal_connect (dialog, "response", G_CALLBACK (on_edit_save_clicked), state); gtk_widget_show_all (dialog); } /* Refresh the food log list for the currently selected date. */ static void refresh_log_list (struct gui_state *state) { struct log_list entries; const char *date; GList *children; GList *iter; char frame_title[128]; /* Remove existing rows. */ children = gtk_container_get_children (GTK_CONTAINER (state->log_list_box)); for (iter = children; iter; iter = iter->next) gtk_widget_destroy (GTK_WIDGET (iter->data)); g_list_free (children); date = state->log_date; /* Update the log frame title to show the selected date. */ if (state->log_frame) { if (strcmp (date, today_date ()) == 0) snprintf (frame_title, sizeof frame_title, _("Food Log - %s (Today)"), format_date (date)); else snprintf (frame_title, sizeof frame_title, _("Food Log - %s"), format_date (date)); gtk_frame_set_label (GTK_FRAME (state->log_frame), frame_title); } if (log_get_day (state->log_db, date, &entries) == 0) { size_t i; if (entries.count == 0) { GtkWidget *label = gtk_label_new (_("No entries for this date.")); gtk_widget_set_halign (label, GTK_ALIGN_START); gtk_container_add (GTK_CONTAINER (state->log_list_box), label); } else { for (i = 0; i < entries.count; i++) { char row_text[512]; GtkWidget *hbox; GtkWidget *label; GtkWidget *edit_btn; GtkWidget *del_btn; double *srv_copy; hbox = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); snprintf (row_text, sizeof row_text, "%d - %s (%.1f %s)", entries.items[i].food_code, entries.items[i].description, entries.items[i].servings, _("servings")); label = gtk_label_new (row_text); gtk_widget_set_halign (label, GTK_ALIGN_START); gtk_label_set_xalign (GTK_LABEL (label), 0.0); gtk_box_pack_start (GTK_BOX (hbox), label, TRUE, TRUE, 0); edit_btn = gtk_button_new_with_label (_("Edit")); del_btn = gtk_button_new_with_label (_("Delete")); /* Store entry metadata on the buttons. */ g_object_set_data (G_OBJECT (edit_btn), "entry-id", GINT_TO_POINTER (entries.items[i].id)); srv_copy = g_new (double, 1); *srv_copy = entries.items[i].servings; g_object_set_data_full (G_OBJECT (edit_btn), "entry-servings", srv_copy, g_free); g_object_set_data_full (G_OBJECT (edit_btn), "entry-date", g_strdup (entries.items[i].date), g_free); g_object_set_data_full (G_OBJECT (edit_btn), "entry-desc", g_strdup (entries.items[i].description), g_free); g_object_set_data (G_OBJECT (del_btn), "entry-id", GINT_TO_POINTER (entries.items[i].id)); g_signal_connect (edit_btn, "clicked", G_CALLBACK (on_log_edit_clicked), state); g_signal_connect (del_btn, "clicked", G_CALLBACK (on_log_delete_clicked), state); gtk_box_pack_end (GTK_BOX (hbox), del_btn, FALSE, FALSE, 0); gtk_box_pack_end (GTK_BOX (hbox), edit_btn, FALSE, FALSE, 0); gtk_container_add (GTK_CONTAINER (state->log_list_box), hbox); } } log_list_free (&entries); } gtk_widget_show_all (state->log_list_box); } /* Refresh both the dashboard and log list. */ static void refresh_all (struct gui_state *state) { refresh_dashboard (state); refresh_log_list (state); } /* --- Search Dialog --- */ enum { COL_FOOD_CODE, COL_DESCRIPTION, NUM_COLS }; /* Callback for food detail dialog "Add" button. */ static void on_food_add_clicked (GtkDialog *dialog, gint response_id, gpointer user_data) { struct gui_state *state = user_data; if (response_id == GTK_RESPONSE_OK) { GtkWidget *content; GList *children; GtkWidget *spin; GtkWidget *calendar; int food_code; double quantity; const char *description; char date[11]; guint year, month, day; content = gtk_dialog_get_content_area (dialog); children = gtk_container_get_children (GTK_CONTAINER (content)); /* The spin button is attached as object data on the dialog. */ spin = g_object_get_data (G_OBJECT (dialog), "spin-quantity"); calendar = g_object_get_data (G_OBJECT (dialog), "calendar"); food_code = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (dialog), "food-code")); description = g_object_get_data (G_OBJECT (dialog), "food-desc"); quantity = gtk_spin_button_get_value (GTK_SPIN_BUTTON (spin)); gtk_calendar_get_date (GTK_CALENDAR (calendar), &year, &month, &day); snprintf (date, sizeof date, "%04u-%02u-%02u", year, month + 1, day); log_add (state->log_db, food_code, description, date, quantity); refresh_all (state); g_list_free (children); } gtk_widget_destroy (GTK_WIDGET (dialog)); } /* Populate the nutrient grid with values scaled by SERVINGS. */ static void populate_nutrient_grid (GtkWidget *grid, sqlite3 *food_db, int food_code, double servings) { GtkWidget *lbl; struct nutrient_list nutrients; struct fped_entry fped; int row; size_t i; GList *children, *iter; /* Clear existing grid content. */ children = gtk_container_get_children (GTK_CONTAINER (grid)); for (iter = children; iter; iter = iter->next) gtk_widget_destroy (GTK_WIDGET (iter->data)); g_list_free (children); row = 0; if (db_get_nutrients (food_db, food_code, &nutrients) == 0) { lbl = gtk_label_new (NULL); gtk_label_set_markup (GTK_LABEL (lbl), _("Nutrient")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); lbl = gtk_label_new (NULL); gtk_label_set_markup (GTK_LABEL (lbl), _("Value")); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; for (i = 0; i < nutrients.count; i++) { char val_buf[32]; lbl = gtk_label_new (nutrients.items[i].name); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", nutrients.items[i].value * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; } nutrient_list_free (&nutrients); } /* FPED info. */ if (db_get_fped (food_db, food_code, &fped) == 0) { char val_buf[32]; row++; lbl = gtk_label_new (NULL); gtk_label_set_markup (GTK_LABEL (lbl), _("Food Pattern Equivalents")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 2, 1); row++; lbl = gtk_label_new (_("Vegetables (cup-eq)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.vegetables * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; lbl = gtk_label_new (_("Fruits (cup-eq)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.fruits * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; lbl = gtk_label_new (_("Grains (oz-eq)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.grains * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; lbl = gtk_label_new (_("Dairy (cup-eq)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.dairy * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; lbl = gtk_label_new (_("Protein (oz-eq)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.protein * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); row++; lbl = gtk_label_new (_("Oils (g)")); gtk_widget_set_halign (lbl, GTK_ALIGN_START); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); snprintf (val_buf, sizeof val_buf, "%.2f", fped.oils * servings); lbl = gtk_label_new (val_buf); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 1, row, 1, 1); } gtk_widget_show_all (grid); } /* Callback when the servings spin button changes in the food detail dialog. Rebuilds the nutrient grid to reflect the new quantity. */ static void on_detail_quantity_changed (GtkSpinButton *spin, gpointer user_data) { GtkWidget *dialog = GTK_WIDGET (user_data); GtkWidget *grid; sqlite3 *food_db; int food_code; double servings; grid = g_object_get_data (G_OBJECT (dialog), "nutrient-grid"); food_db = g_object_get_data (G_OBJECT (dialog), "food-db"); food_code = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (dialog), "food-code")); servings = gtk_spin_button_get_value (spin); populate_nutrient_grid (grid, food_db, food_code, servings); } /* Show a food detail dialog with nutrient info and an Add button. */ static void show_food_detail (struct gui_state *state, int food_code, const char *description) { GtkWidget *dialog; GtkWidget *content; GtkWidget *scroll; GtkWidget *grid; GtkWidget *spin; GtkWidget *calendar; GtkWidget *lbl; dialog = gtk_dialog_new_with_buttons (description, GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, _("Add to Log"), GTK_RESPONSE_OK, _("Cancel"), GTK_RESPONSE_CANCEL, NULL); gtk_window_set_default_size (GTK_WINDOW (dialog), 500, 500); content = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); /* Quantity spinner and date picker. */ { GtkWidget *hbox = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); lbl = gtk_label_new (_("Servings:")); spin = gtk_spin_button_new_with_range (0.1, 100.0, 0.5); gtk_spin_button_set_value (GTK_SPIN_BUTTON (spin), 1.0); gtk_box_pack_start (GTK_BOX (hbox), lbl, FALSE, FALSE, 6); gtk_box_pack_start (GTK_BOX (hbox), spin, FALSE, FALSE, 6); gtk_box_pack_start (GTK_BOX (content), hbox, FALSE, FALSE, 6); } /* Date selector. */ { GtkWidget *date_frame = gtk_frame_new (_("Log Date")); calendar = gtk_calendar_new (); gtk_container_add (GTK_CONTAINER (date_frame), calendar); gtk_box_pack_start (GTK_BOX (content), date_frame, FALSE, FALSE, 6); } g_object_set_data (G_OBJECT (dialog), "spin-quantity", spin); g_object_set_data (G_OBJECT (dialog), "calendar", calendar); g_object_set_data (G_OBJECT (dialog), "food-code", GINT_TO_POINTER (food_code)); g_object_set_data_full (G_OBJECT (dialog), "food-desc", g_strdup (description), g_free); g_object_set_data (G_OBJECT (dialog), "food-db", state->food_db); /* Nutrient info in a scrolled grid. */ scroll = gtk_scrolled_window_new (NULL, NULL); gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); grid = gtk_grid_new (); gtk_grid_set_column_spacing (GTK_GRID (grid), 12); gtk_grid_set_row_spacing (GTK_GRID (grid), 2); g_object_set_data (G_OBJECT (dialog), "nutrient-grid", grid); populate_nutrient_grid (grid, state->food_db, food_code, 1.0); gtk_container_add (GTK_CONTAINER (scroll), grid); gtk_box_pack_start (GTK_BOX (content), scroll, TRUE, TRUE, 0); g_signal_connect (spin, "value-changed", G_CALLBACK (on_detail_quantity_changed), dialog); g_signal_connect (dialog, "response", G_CALLBACK (on_food_add_clicked), state); gtk_widget_show_all (dialog); } /* Callback when a row in the search results tree view is activated. */ static void on_search_row_activated (GtkTreeView *tree_view, GtkTreePath *path, GtkTreeViewColumn *column, gpointer user_data) { struct gui_state *state = user_data; GtkTreeModel *model; GtkTreeIter iter; (void) column; model = gtk_tree_view_get_model (tree_view); if (gtk_tree_model_get_iter (model, &iter, path)) { gint food_code; gchar *description; gtk_tree_model_get (model, &iter, COL_FOOD_CODE, &food_code, COL_DESCRIPTION, &description, -1); show_food_detail (state, food_code, description); g_free (description); } } /* Callback for search entry text changes (live search). */ static void on_search_changed (GtkSearchEntry *entry, gpointer user_data) { GtkListStore *store = user_data; struct gui_state *state; const gchar *query; struct food_list results; size_t i; state = g_object_get_data (G_OBJECT (entry), "gui-state"); query = gtk_entry_get_text (GTK_ENTRY (entry)); gtk_list_store_clear (store); if (query[0] == '\0') return; if (db_search_foods (state->food_db, query, &results) == 0) { for (i = 0; i < results.count; i++) { GtkTreeIter iter; gtk_list_store_append (store, &iter); gtk_list_store_set (store, &iter, COL_FOOD_CODE, results.items[i].food_code, COL_DESCRIPTION, results.items[i].description, -1); } food_list_free (&results); } } /* Show the Search dialog. */ static void on_search_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; GtkWidget *dialog; GtkWidget *content; GtkWidget *search_entry; GtkWidget *scroll; GtkWidget *tree; GtkListStore *store; GtkCellRenderer *renderer; GtkTreeViewColumn *col; (void) button; dialog = gtk_dialog_new_with_buttons (_("Search Foods"), GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, _("Close"), GTK_RESPONSE_CLOSE, NULL); gtk_window_set_default_size (GTK_WINDOW (dialog), 600, 450); content = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); /* Search entry. */ search_entry = gtk_search_entry_new (); gtk_entry_set_placeholder_text (GTK_ENTRY (search_entry), _("Type to search foods...")); gtk_box_pack_start (GTK_BOX (content), search_entry, FALSE, FALSE, 6); /* Results list. */ store = gtk_list_store_new (NUM_COLS, G_TYPE_INT, G_TYPE_STRING); tree = gtk_tree_view_new_with_model (GTK_TREE_MODEL (store)); g_object_unref (store); renderer = gtk_cell_renderer_text_new (); col = gtk_tree_view_column_new_with_attributes (_("Code"), renderer, "text", COL_FOOD_CODE, NULL); gtk_tree_view_append_column (GTK_TREE_VIEW (tree), col); renderer = gtk_cell_renderer_text_new (); col = gtk_tree_view_column_new_with_attributes (_("Description"), renderer, "text", COL_DESCRIPTION, NULL); gtk_tree_view_column_set_expand (col, TRUE); gtk_tree_view_append_column (GTK_TREE_VIEW (tree), col); g_object_set_data (G_OBJECT (search_entry), "gui-state", state); g_signal_connect (search_entry, "search-changed", G_CALLBACK (on_search_changed), store); g_signal_connect (tree, "row-activated", G_CALLBACK (on_search_row_activated), state); scroll = gtk_scrolled_window_new (NULL, NULL); gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); gtk_container_add (GTK_CONTAINER (scroll), tree); gtk_box_pack_start (GTK_BOX (content), scroll, TRUE, TRUE, 0); gtk_widget_show_all (dialog); g_signal_connect (dialog, "response", G_CALLBACK (gtk_widget_destroy), NULL); } /* Profile dialog response handler. */ static void on_profile_response (GtkDialog *dialog, gint response_id, gpointer user_data) { struct gui_state *state = user_data; if (response_id == GTK_RESPONSE_OK) { GtkWidget *age_spin, *height_spin, *weight_spin, *activity_combo, *gender_combo; struct user_profile prof; age_spin = g_object_get_data (G_OBJECT (dialog), "age-spin"); height_spin = g_object_get_data (G_OBJECT (dialog), "height-spin"); weight_spin = g_object_get_data (G_OBJECT (dialog), "weight-spin"); activity_combo = g_object_get_data (G_OBJECT (dialog), "activity-combo"); gender_combo = g_object_get_data (G_OBJECT (dialog), "gender-combo"); prof.age_years = gtk_spin_button_get_value_as_int (GTK_SPIN_BUTTON (age_spin)); prof.height_cm = gtk_spin_button_get_value (GTK_SPIN_BUTTON (height_spin)); prof.weight_kg = gtk_spin_button_get_value (GTK_SPIN_BUTTON (weight_spin)); prof.activity_level = gtk_combo_box_get_active (GTK_COMBO_BOX (activity_combo)); prof.gender = gtk_combo_box_get_active (GTK_COMBO_BOX (gender_combo)); prof.calorie_target = budget_estimate_calories (prof.age_years, prof.height_cm, prof.weight_kg, prof.activity_level, prof.gender); if (log_save_profile (state->log_db, &prof) == 0) { state->calories = prof.calorie_target; state->dbus_ctx.calories = prof.calorie_target; refresh_all (state); } } gtk_widget_destroy (GTK_WIDGET (dialog)); } /* Show the Profile dialog. */ static void on_profile_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; GtkWidget *dialog, *lbl, *content, *grid; GtkWidget *age_spin, *height_spin, *weight_spin; GtkWidget *activity_combo, *gender_combo; struct user_profile prof; int rc; int row; (void) button; dialog = gtk_dialog_new_with_buttons (_("Profile"), GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, _("Save"), GTK_RESPONSE_OK, _("Cancel"), GTK_RESPONSE_CANCEL, NULL); content = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); grid = gtk_grid_new (); gtk_grid_set_column_spacing (GTK_GRID (grid), 12); gtk_grid_set_row_spacing (GTK_GRID (grid), 6); gtk_widget_set_margin_start (grid, 12); gtk_widget_set_margin_end (grid, 12); gtk_widget_set_margin_top (grid, 12); gtk_widget_set_margin_bottom (grid, 12); row = 0; /* Age. */ lbl = gtk_label_new (_("Age (years):")); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); age_spin = gtk_spin_button_new_with_range (1, 120, 1); gtk_grid_attach (GTK_GRID (grid), age_spin, 1, row, 1, 1); row++; /* Height. */ lbl = gtk_label_new (_("Height (cm):")); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); height_spin = gtk_spin_button_new_with_range (30.0, 300.0, 0.5); gtk_grid_attach (GTK_GRID (grid), height_spin, 1, row, 1, 1); row++; /* Weight. */ lbl = gtk_label_new (_("Weight (kg):")); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); weight_spin = gtk_spin_button_new_with_range (1.0, 500.0, 0.5); gtk_grid_attach (GTK_GRID (grid), weight_spin, 1, row, 1, 1); row++; /* Activity level. */ lbl = gtk_label_new (_("Activity level:")); gtk_widget_set_halign (lbl, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), lbl, 0, row, 1, 1); activity_combo = gtk_combo_box_text_new (); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (activity_combo), _("Sedentary")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (activity_combo), _("Light")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (activity_combo), _("Moderate")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (activity_combo), _("Very active")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (activity_combo), _("Extra active")); gtk_combo_box_set_active (GTK_COMBO_BOX (activity_combo), ACTIVITY_SEDENTARY); gender_combo = gtk_combo_box_text_new (); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (gender_combo), _("Neutral")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (gender_combo), _("Female")); gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (gender_combo), _("Male")); gtk_grid_attach (GTK_GRID (grid), activity_combo, 1, row, 1, 1); /* Load existing profile. */ rc = log_get_profile (state->log_db, &prof); if (rc == 0) { gtk_spin_button_set_value (GTK_SPIN_BUTTON (age_spin), prof.age_years); gtk_spin_button_set_value (GTK_SPIN_BUTTON (height_spin), prof.height_cm); gtk_spin_button_set_value (GTK_SPIN_BUTTON (weight_spin), prof.weight_kg); gtk_combo_box_set_active (GTK_COMBO_BOX (activity_combo), prof.activity_level); gtk_combo_box_set_active (GTK_COMBO_BOX (gender_combo), prof.gender); } else { gtk_combo_box_set_active (GTK_COMBO_BOX (gender_combo), 0); } g_object_set_data (G_OBJECT (dialog), "age-spin", age_spin); g_object_set_data (G_OBJECT (dialog), "height-spin", height_spin); g_object_set_data (G_OBJECT (dialog), "weight-spin", weight_spin); g_object_set_data (G_OBJECT (dialog), "activity-combo", activity_combo); g_object_set_data (G_OBJECT (dialog), "gender-combo", gender_combo); GtkWidget *gender_label = gtk_label_new (_("Gender:")); gtk_widget_set_halign (gender_label, GTK_ALIGN_END); gtk_grid_attach (GTK_GRID (grid), gender_label, 0, 4, 1, 1); gtk_grid_attach (GTK_GRID (grid), gender_combo, 1, 4, 1, 1); gtk_box_pack_start (GTK_BOX (content), grid, TRUE, TRUE, 0); g_signal_connect (dialog, "response", G_CALLBACK (on_profile_response), state); gtk_widget_show_all (dialog); } /* Navigate the food log to the previous date. */ static void on_log_prev_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; struct date_list dates; size_t i; (void) button; if (log_get_dates (state->log_db, &dates) != 0 || dates.count == 0) return; /* Find the current date in the list and go to the previous one. */ for (i = 0; i < dates.count; i++) { if (strcmp (dates.dates[i], state->log_date) == 0) break; } if (i > 0 && i <= dates.count) { /* Move to the previous date. If current date was not found (i == dates.count), pick the last date before it. */ size_t target = (i < dates.count) ? i - 1 : dates.count - 1; strncpy (state->log_date, dates.dates[target], sizeof state->log_date - 1); state->log_date[sizeof state->log_date - 1] = '\0'; refresh_all (state); } date_list_free (&dates); } /* Navigate the food log to the next date. */ static void on_log_next_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; struct date_list dates; size_t i; (void) button; if (log_get_dates (state->log_db, &dates) != 0 || dates.count == 0) return; /* Find the current date in the list and go to the next one. */ for (i = 0; i < dates.count; i++) { if (strcmp (dates.dates[i], state->log_date) == 0) break; } if (i < dates.count && i + 1 < dates.count) { strncpy (state->log_date, dates.dates[i + 1], sizeof state->log_date - 1); state->log_date[sizeof state->log_date - 1] = '\0'; refresh_all (state); } date_list_free (&dates); } /* Navigate the food log back to today's date. */ static void on_log_today_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; (void) button; strncpy (state->log_date, today_date (), sizeof state->log_date - 1); state->log_date[sizeof state->log_date - 1] = '\0'; refresh_all (state); } /* Show the About dialog. */ static void on_about_clicked (GtkButton *button, gpointer user_data) { struct gui_state *state = user_data; GtkWidget *dialog; char version_text[512]; (void) button; snprintf (version_text, sizeof version_text, _("GNUtrition %s\n" "Copyright (C) 2026 Free Software Foundation, Inc.\n" "License GPLv3+: GNU GPL version 3 or later " "\n" "This is free software: you are free to change " "and redistribute it.\n" "There is NO WARRANTY, to the extent permitted by law."), PACKAGE_VERSION); dialog = gtk_message_dialog_new (GTK_WINDOW (state->window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_INFO, GTK_BUTTONS_CLOSE, "%s", version_text); gtk_window_set_title (GTK_WINDOW (dialog), _("About GNUtrition")); g_signal_connect (dialog, "response", G_CALLBACK (gtk_widget_destroy), NULL); gtk_widget_show_all (dialog); } /* Create a single GtkProgressBar for a budget category. */ static GtkWidget * make_progress_bar (void) { GtkWidget *pb; pb = gtk_progress_bar_new (); gtk_progress_bar_set_show_text (GTK_PROGRESS_BAR (pb), TRUE); gtk_widget_set_hexpand (pb, TRUE); return pb; } /* Build and show the main window. */ static void on_activate (GtkApplication *app, gpointer user_data) { struct gui_state *state = user_data; GtkWidget *header; GtkWidget *search_btn, *profile_btn, *about_btn; GtkWidget *main_box; GtkWidget *dash_frame, *log_frame; GtkWidget *dash_box; GtkWidget *log_scroll; GtkWidget *log_vbox; GtkWidget *log_nav_box; GtkWidget *prev_btn, *today_btn, *next_btn; char budget_text[128]; /* Initialize the log date to today. */ strncpy (state->log_date, today_date (), sizeof state->log_date - 1); state->log_date[sizeof state->log_date - 1] = '\0'; /* Main window. */ state->window = gtk_application_window_new (app); gtk_window_set_title (GTK_WINDOW (state->window), _("GNUtrition")); gtk_window_set_default_size (GTK_WINDOW (state->window), 700, 550); /* Header bar. */ header = gtk_header_bar_new (); gtk_header_bar_set_title (GTK_HEADER_BAR (header), _("GNUtrition")); gtk_header_bar_set_show_close_button (GTK_HEADER_BAR (header), TRUE); search_btn = gtk_button_new_with_label (_("Search")); profile_btn = gtk_button_new_with_label (_("Profile")); about_btn = gtk_button_new_with_label (_("About")); gtk_header_bar_pack_start (GTK_HEADER_BAR (header), search_btn); gtk_header_bar_pack_end (GTK_HEADER_BAR (header), about_btn); gtk_header_bar_pack_end (GTK_HEADER_BAR (header), profile_btn); gtk_window_set_titlebar (GTK_WINDOW (state->window), header); g_signal_connect (search_btn, "clicked", G_CALLBACK (on_search_clicked), state); g_signal_connect (profile_btn, "clicked", G_CALLBACK (on_profile_clicked), state); g_signal_connect (about_btn, "clicked", G_CALLBACK (on_about_clicked), state); /* Main layout: vertical box with dashboard on top and log on bottom. */ main_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 6); gtk_widget_set_margin_start (main_box, 12); gtk_widget_set_margin_end (main_box, 12); gtk_widget_set_margin_top (main_box, 12); gtk_widget_set_margin_bottom (main_box, 12); /* Dashboard frame. */ dash_frame = gtk_frame_new (_("Daily Budget")); dash_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 4); gtk_widget_set_margin_start (dash_box, 8); gtk_widget_set_margin_end (dash_box, 8); gtk_widget_set_margin_top (dash_box, 4); gtk_widget_set_margin_bottom (dash_box, 8); snprintf (budget_text, sizeof budget_text, _("USDA Healthy US-Style Eating Pattern (%d kcal)"), state->calories); state->budget_label = gtk_label_new (budget_text); gtk_widget_set_halign (state->budget_label, GTK_ALIGN_START); gtk_box_pack_start (GTK_BOX (dash_box), state->budget_label, FALSE, FALSE, 2); state->pb_vegetables = make_progress_bar (); state->pb_fruits = make_progress_bar (); state->pb_grains = make_progress_bar (); state->pb_dairy = make_progress_bar (); state->pb_protein = make_progress_bar (); state->pb_oils = make_progress_bar (); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_vegetables, FALSE, FALSE, 2); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_fruits, FALSE, FALSE, 2); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_grains, FALSE, FALSE, 2); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_dairy, FALSE, FALSE, 2); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_protein, FALSE, FALSE, 2); gtk_box_pack_start (GTK_BOX (dash_box), state->pb_oils, FALSE, FALSE, 2); gtk_container_add (GTK_CONTAINER (dash_frame), dash_box); state->dashboard_box = dash_box; /* Log frame with date navigation. */ log_frame = gtk_frame_new (_("Food Log")); state->log_frame = log_frame; log_vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 2); /* Date navigation bar. */ log_nav_box = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 4); gtk_widget_set_margin_start (log_nav_box, 8); gtk_widget_set_margin_end (log_nav_box, 8); gtk_widget_set_margin_top (log_nav_box, 4); prev_btn = gtk_button_new_with_label ("\342\227\200"); /* U+25C0 ◀ */ today_btn = gtk_button_new_with_label (_("Today")); next_btn = gtk_button_new_with_label ("\342\226\266"); /* U+25B6 ▶ */ gtk_box_pack_start (GTK_BOX (log_nav_box), prev_btn, FALSE, FALSE, 0); gtk_box_pack_start (GTK_BOX (log_nav_box), today_btn, FALSE, FALSE, 0); gtk_box_pack_start (GTK_BOX (log_nav_box), next_btn, FALSE, FALSE, 0); g_signal_connect (prev_btn, "clicked", G_CALLBACK (on_log_prev_clicked), state); g_signal_connect (today_btn, "clicked", G_CALLBACK (on_log_today_clicked), state); g_signal_connect (next_btn, "clicked", G_CALLBACK (on_log_next_clicked), state); gtk_box_pack_start (GTK_BOX (log_vbox), log_nav_box, FALSE, FALSE, 0); log_scroll = gtk_scrolled_window_new (NULL, NULL); gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (log_scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); state->log_list_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 2); gtk_widget_set_margin_start (state->log_list_box, 8); gtk_widget_set_margin_end (state->log_list_box, 8); gtk_widget_set_margin_top (state->log_list_box, 4); gtk_widget_set_margin_bottom (state->log_list_box, 4); gtk_container_add (GTK_CONTAINER (log_scroll), state->log_list_box); gtk_box_pack_start (GTK_BOX (log_vbox), log_scroll, TRUE, TRUE, 0); gtk_container_add (GTK_CONTAINER (log_frame), log_vbox); gtk_box_pack_start (GTK_BOX (main_box), dash_frame, FALSE, FALSE, 0); gtk_box_pack_start (GTK_BOX (main_box), log_frame, TRUE, TRUE, 0); gtk_container_add (GTK_CONTAINER (state->window), main_box); /* Populate data. */ refresh_all (state); gtk_widget_show_all (state->window); } int gui_run (sqlite3 *food_db, sqlite3 *log_db, int calories, int argc, char **argv) { GtkApplication *app; struct gui_state state; int status; memset (&state, 0, sizeof state); state.food_db = food_db; state.log_db = log_db; state.calories = calories; /* Start D-Bus service. */ state.dbus_ctx.food_db = food_db; state.dbus_ctx.log_db = log_db; state.dbus_ctx.calories = calories; state.dbus_owner_id = dbus_service_start (&state.dbus_ctx); app = gtk_application_new ("org.gnu.gnutrition", G_APPLICATION_DEFAULT_FLAGS); g_signal_connect (app, "activate", G_CALLBACK (on_activate), &state); status = g_application_run (G_APPLICATION (app), argc, argv); g_object_unref (app); /* Stop D-Bus service. */ dbus_service_stop (state.dbus_owner_id); return status; }