From 1fb9defc9b7b9849a91284e4e6c22534fd612b2a Mon Sep 17 00:00:00 2001 From: Eliryen Date: Sun, 30 Aug 2026 23:56:43 +0200 Subject: [PATCH 1/2] group same/similar notification into a single row. -added: Notifications (3+) with the same app name and title are grouped into a single row with a count. -Added: when you select a notification group, only the group's notifications are shown in the preview, not all. --- src/fw/apps/system/notifications.c | 672 ++++++++++++++++++++++------- 1 file changed, 528 insertions(+), 144 deletions(-) diff --git a/src/fw/apps/system/notifications.c b/src/fw/apps/system/notifications.c index f211b014d4..6fd6510a62 100644 --- a/src/fw/apps/system/notifications.c +++ b/src/fw/apps/system/notifications.c @@ -46,12 +46,20 @@ typedef struct NotificationNode { ListNode node; Uuid id; } NotificationNode; - +typedef struct NotificationDisplayNode { + ListNode node; + bool is_group; + Uuid representative_id; + uint16_t count; + // Timestamp used to sort this row (for a group, this is its most recent member's timestamp). + time_t timestamp; +} NotificationDisplayNode; typedef struct NotificationsData { Window window; MenuLayer menu_layer; TextLayer text_layer; NotificationNode *notification_list; + NotificationDisplayNode *display_list; LoadedNotificationNode *loaded_notification_list; EventServiceInfo notification_event_info; ActionableDialog *actionable_dialog; @@ -62,6 +70,75 @@ typedef struct NotificationsData { static NotificationsData *s_data = NULL; +static NotificationDisplayNode *prv_display_list_add(NotificationDisplayNode **display_list, + Uuid *id, bool is_group, uint16_t count, + time_t timestamp) { + NotificationDisplayNode *new_node = app_malloc_check(sizeof(NotificationDisplayNode)); + list_init((ListNode *)new_node); + new_node->is_group = is_group; + new_node->representative_id = *id; + new_node->count = count; + new_node->timestamp = timestamp; + *display_list = + (NotificationDisplayNode *)list_prepend((ListNode *)*display_list, (ListNode *)new_node); + return new_node; +} + +static void prv_display_list_deinit(NotificationDisplayNode *display_list) { + while (display_list) { + NotificationDisplayNode *node = display_list; + display_list = (NotificationDisplayNode *)list_pop_head((ListNode *)display_list); + app_free(node); + } +} + +// Sorts the display list in place by timestamp, fixing incorrect ordering of both single +// and grouped rows. `sort_newest_first` stays a plain parameter until wired to a real setting. +static void prv_display_list_sort(NotificationDisplayNode **display_list, + bool sort_newest_first) { + const uint32_t count = list_count((ListNode *)*display_list); + if (count < 2) { + return; + } + + NotificationDisplayNode **nodes = app_malloc_check(count * sizeof(NotificationDisplayNode *)); + for (uint32_t i = 0; i < count; i++) { + nodes[i] = (NotificationDisplayNode *)list_get_at((ListNode *)*display_list, i); + } + + // Simple insertion sort: the display list only ever holds a handful of visible rows, so + // O(n^2) is a non-issue here, and it avoids pulling in a libc qsort dependency. + for (uint32_t i = 1; i < count; i++) { + NotificationDisplayNode *key = nodes[i]; + int32_t j = (int32_t)i - 1; + while (j >= 0) { + const bool should_shift = sort_newest_first ? (nodes[j]->timestamp < key->timestamp) + : (nodes[j]->timestamp > key->timestamp); + if (!should_shift) { + break; + } + nodes[j + 1] = nodes[j]; + j--; + } + nodes[j + 1] = key; + } + + NotificationDisplayNode *sorted_list = NULL; + for (int32_t i = (int32_t)count - 1; i >= 0; i--) { + list_init((ListNode *)nodes[i]); + sorted_list = + (NotificationDisplayNode *)list_prepend((ListNode *)sorted_list, (ListNode *)nodes[i]); + } + + *display_list = sorted_list; + app_free(nodes); +} + +// Default sort direction: true = most recent notification at the top (normal behavior). +// Deliberately not read from shell_prefs or any settings store yet - swap this constant for a +// real preference getter later and every call site below will pick it up automatically. +static const bool NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT = true; + static const unsigned int MAX_ACTIVE_NOTIFICATIONS = 6; static bool prv_loaded_notification_list_filter_cb(ListNode *node, void *data) { @@ -77,41 +154,37 @@ static bool prv_notification_list_filter_cb(ListNode *node, void *data) { } static NotificationNode *prv_find_notification(NotificationNode *list, Uuid *id) { - return (NotificationNode *)list_find((ListNode *)list, - prv_notification_list_filter_cb, - id); + return (NotificationNode *)list_find((ListNode *)list, prv_notification_list_filter_cb, id); } static LoadedNotificationNode *prv_find_loaded_notification(LoadedNotificationNode *list, Uuid *id) { return (LoadedNotificationNode *)list_find((ListNode *)list, - prv_loaded_notification_list_filter_cb, - id); + prv_loaded_notification_list_filter_cb, id); } static NotificationNode *prv_notification_list_add_notification_by_id( NotificationNode **notification_list, Uuid *id) { NotificationNode *new_node = app_malloc_check(sizeof(NotificationNode)); - list_init((ListNode*) new_node); + list_init((ListNode *)new_node); new_node->id = *id; - *notification_list = (NotificationNode*) list_prepend((ListNode*) *notification_list, - (ListNode*) new_node); + *notification_list = + (NotificationNode *)list_prepend((ListNode *)*notification_list, (ListNode *)new_node); return new_node; } -static void prv_notification_list_remove_notification_by_id( - NotificationNode **notification_list, Uuid *id) { - +static void prv_notification_list_remove_notification_by_id(NotificationNode **notification_list, + Uuid *id) { NotificationNode *node = prv_find_notification(*notification_list, id); list_remove((ListNode *)node, (ListNode **)notification_list, NULL); } static NotificationNode *prv_add_notification(NotificationsData *data, Uuid *id) { - NotificationNode *node = prv_notification_list_add_notification_by_id(&data->notification_list, - id); + NotificationNode *node = + prv_notification_list_add_notification_by_id(&data->notification_list, id); return node; } @@ -130,7 +203,7 @@ static void prv_load_notification_storage(NotificationsData *data) { static void prv_notification_list_deinit(NotificationNode *notification_list) { while (notification_list) { NotificationNode *node = notification_list; - notification_list = (NotificationNode*) list_pop_head((ListNode*) notification_list); + notification_list = (NotificationNode *)list_pop_head((ListNode *)notification_list); app_free(node); } } @@ -153,10 +226,10 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( } // unload old notifications - if (list_count((ListNode*) *loaded_list) > MAX_ACTIVE_NOTIFICATIONS) { - LoadedNotificationNode *old_node = (LoadedNotificationNode*) list_get_tail( - (ListNode*) *loaded_list); - list_remove((ListNode*) old_node, (ListNode**) loaded_list, NULL); + if (list_count((ListNode *)*loaded_list) > MAX_ACTIVE_NOTIFICATIONS) { + LoadedNotificationNode *old_node = + (LoadedNotificationNode *)list_get_tail((ListNode *)*loaded_list); + list_remove((ListNode *)old_node, (ListNode **)loaded_list, NULL); prv_unload_loaded_notification(old_node); } @@ -169,12 +242,11 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( // track the loaded notification loaded_node = app_malloc_check(sizeof(LoadedNotificationNode)); - list_init((ListNode*) loaded_node); + list_init((ListNode *)loaded_node); loaded_node->notification = notification; - TimelineResourceId timeline_res_id = attribute_get_uint32(¬ification.attr_list, - AttributeIdIconTiny, - NOTIF_FALLBACK_ICON); + TimelineResourceId timeline_res_id = + attribute_get_uint32(¬ification.attr_list, AttributeIdIconTiny, NOTIF_FALLBACK_ICON); // Read the associated pin's app id TimelineItem pin; @@ -183,11 +255,9 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( pin.header.parent_id = (Uuid)UUID_INVALID; } - TimelineResourceInfo timeline_res = { - .res_id = timeline_res_id, - .app_id = &pin.header.parent_id, - .fallback_id = NOTIF_FALLBACK_ICON - }; + TimelineResourceInfo timeline_res = {.res_id = timeline_res_id, + .app_id = &pin.header.parent_id, + .fallback_id = NOTIF_FALLBACK_ICON}; AppResourceInfo icon_res_info; timeline_resources_get_id(&timeline_res, TimelineResourceSizeTiny, &icon_res_info); loaded_node->icon = gdraw_command_image_create_with_resource_system(icon_res_info.res_app_num, @@ -195,8 +265,8 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( loaded_node->icon_is_default = (timeline_res_id == NOTIF_FALLBACK_ICON) || (timeline_res_id == TIMELINE_RESOURCE_NOTIFICATION_GENERIC); - *loaded_list = (LoadedNotificationNode*) list_prepend((ListNode*) *loaded_list, - (ListNode*)loaded_node); + *loaded_list = + (LoadedNotificationNode *)list_prepend((ListNode *)*loaded_list, (ListNode *)loaded_node); return loaded_node; } @@ -204,13 +274,14 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( static void prv_loaded_notification_list_deinit(LoadedNotificationNode *loaded_list) { while (loaded_list) { LoadedNotificationNode *node = loaded_list; - loaded_list = (LoadedNotificationNode*) list_pop_head((ListNode*) loaded_list); + loaded_list = (LoadedNotificationNode *)list_pop_head((ListNode *)loaded_list); prv_unload_loaded_notification(node); } } -// Return true if successful -static bool prv_push_notification_window(NotificationsData *data) { +// Return true if successful. `list` populates notification_window - normally the full +// notification_list, but a group-select flow can pass a filtered temporary list instead. +static bool prv_push_notification_window_with_list(NotificationNode *list) { notification_window_init(false /*is_modal*/); // Bail if a notification came in ahead of us and created a modal window @@ -221,16 +292,21 @@ static bool prv_push_notification_window(NotificationsData *data) { // iterate over visible items as visible (including the groups) in reverse order // since notification_window shows each newly added notification first - NotificationNode *node = (NotificationNode*)list_get_tail(&data->notification_list->node); + NotificationNode *node = (NotificationNode *)list_get_tail((ListNode *)list); while (node) { notification_window_add_notification_by_id(&node->id); - node = (NotificationNode*)list_get_prev(&node->node); + node = (NotificationNode *)list_get_prev(&node->node); } notification_window_show(); return true; } +// Return true if successful +static bool prv_push_notification_window(NotificationsData *data) { + return prv_push_notification_window_with_list(data->notification_list); +} + /////////////////// // Confirm Dialog @@ -267,7 +343,6 @@ static void prv_confirmed_handler(ClickRecognizerRef recognizer, void *context) app_simple_dialog_push(confirmation_dialog); } - static void prv_dialog_click_config(void *context) { NotificationsData *data = app_state_get_user_data(); window_single_click_subscribe(BUTTON_ID_SELECT, prv_confirmed_handler); @@ -281,15 +356,17 @@ static void prv_settings_clear_history_window_push(NotificationsData *data) { Dialog *dialog = actionable_dialog_get_dialog(actionable_dialog); dialog_set_text(dialog, i18n_get("Clear history?", data)); TimelineResourceInfo timeline_res = { - .res_id = TIMELINE_RESOURCE_GENERIC_QUESTION, + .res_id = TIMELINE_RESOURCE_GENERIC_QUESTION, }; AppResourceInfo icon_res_info; timeline_resources_get_id(&timeline_res, TimelineResourceSizeLarge, &icon_res_info); dialog_set_icon(dialog, icon_res_info.res_id); dialog_set_icon_animate_direction(dialog, DialogIconAnimationFromRight); - dialog_set_callbacks(dialog, &(DialogCallbacks) { - .unload = prv_dialog_unloaded, - }, data); + dialog_set_callbacks(dialog, + &(DialogCallbacks){ + .unload = prv_dialog_unloaded, + }, + data); app_actionable_dialog_push(actionable_dialog); data->actionable_dialog = actionable_dialog; } @@ -304,29 +381,82 @@ static GColor prv_invert_bw_color(GColor color) { return color; } -static void prv_invert_pdc_colors(GDrawCommandProcessor *processor, - GDrawCommand *processed_command, - size_t processed_command_max_size, - const GDrawCommandList* list, +static void prv_invert_pdc_colors(GDrawCommandProcessor *processor, GDrawCommand *processed_command, + size_t processed_command_max_size, const GDrawCommandList *list, const GDrawCommand *command) { - gdraw_command_set_stroke_color(processed_command, + gdraw_command_set_stroke_color( + processed_command, prv_invert_bw_color(gdraw_command_get_stroke_color((GDrawCommand *)command))); - gdraw_command_set_fill_color(processed_command, + gdraw_command_set_fill_color( + processed_command, prv_invert_bw_color(gdraw_command_get_fill_color((GDrawCommand *)command))); } static void prv_draw_pdc_bw_inverted(GContext *ctx, GDrawCommandImage *image, GPoint offset) { GDrawCommandProcessor processor = { - .command = prv_invert_pdc_colors, + .command = prv_invert_pdc_colors, }; gdraw_command_image_draw_processed(ctx, image, offset, &processor); } -#endif // PBL_BW +#endif // PBL_BW ////////////// // MenuLayer callbacks +// Small "bullet list" icon for grouped cells. Not wrapped in #if PBL_RECT since round reuses it. +static void prv_draw_list_icon(GContext *ctx, GPoint origin, GColor color) { + graphics_context_set_fill_color(ctx, color); + + const int16_t row_spacing = 7; + const int16_t bullet_size = 3; + const int16_t line_start_x = origin.x + 8; + const int16_t line_end_x = origin.x + 22; + + for (int16_t i = 0; i < 3; i++) { + const int16_t y = origin.y + (i * row_spacing); + // Both filled as rects (not a stroked line) so bullet and line stay pixel-aligned. + GRect bullet_rect = GRect(origin.x, y - (bullet_size / 2), bullet_size, bullet_size); + graphics_fill_rect(ctx, &bullet_rect); + GRect line_rect = + GRect(line_start_x, y - (bullet_size / 2), line_end_x - line_start_x, bullet_size); + graphics_fill_rect(ctx, &line_rect); + } +} + #if PBL_RECT +static void prv_draw_group_notification_cell_rect(GContext *ctx, const Layer *cell_layer, + const char *title, const char *body) { + const GRect bounds = cell_layer->bounds; + const bool is_highlighted = menu_cell_layer_is_highlighted(cell_layer); + // Same flat fill/text-color flip a normal menu row gets from + // menu_layer_set_highlight_colors: solid accent fill with white content when selected, + // plain white surface with black content otherwise. No border, no rounded corners, no + // peeking "stack" layers underneath - a group row reads as an ordinary row, just with a + // list icon instead of an app icon, matching the rest of the system UI. + const GColor fill_color = + is_highlighted ? PBL_IF_COLOR_ELSE(DEFAULT_NOTIFICATION_COLOR, GColorBlack) : GColorWhite; + const GColor content_color = is_highlighted ? GColorWhite : GColorBlack; + + graphics_context_set_fill_color(ctx, fill_color); + graphics_fill_rect(ctx, &bounds); + + prv_draw_list_icon(ctx, GPoint(bounds.origin.x + 12, bounds.origin.y + 14), content_color); + + graphics_context_set_text_color(ctx, content_color); + + // Title + GFont title_font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); + GRect title_box = GRect(bounds.origin.x + 40, bounds.origin.y + 2, bounds.size.w - 48, 28); + graphics_draw_text(ctx, title, title_font, title_box, GTextOverflowModeTrailingEllipsis, + GTextAlignmentLeft, NULL); + + // Body + GFont body_font = system_theme_get_font_for_default_size(TextStyleFont_Caption); + GRect body_box = GRect(bounds.origin.x + 40, bounds.origin.y + 28, bounds.size.w - 48, + MAX(bounds.size.h - 32, 0)); + graphics_draw_text(ctx, body, body_font, body_box, GTextOverflowModeTrailingEllipsis, + GTextAlignmentLeft, NULL); +} static void prv_draw_notification_cell_rect(GContext *ctx, const Layer *cell_layer, const char *title, const char *subtitle, GDrawCommandImage *icon) { @@ -346,7 +476,7 @@ static void prv_draw_notification_cell_rect(GContext *ctx, const Layer *cell_lay box.origin.x += icon_left_margin; // Align the icon to the left of the draw box, centered vertically - GRect icon_rect = (GRect) { .size = gdraw_command_image_get_bounds_size(icon) }; + GRect icon_rect = (GRect){.size = gdraw_command_image_get_bounds_size(icon)}; grect_align(&icon_rect, &box, GAlignLeft, false /* clip */); draw_func(ctx, icon, icon_rect.origin); @@ -355,10 +485,9 @@ static void prv_draw_notification_cell_rect(GContext *ctx, const Layer *cell_lay // Temporarily inset the cell layer's bounds from the left so the text doesn't draw over any // icon on the left Layer *mutable_cell_layer = (Layer *)cell_layer; - const int text_left_margin = - icon_left_margin + MAX(icon_size.w, ATTRIBUTE_ICON_TINY_SIZE_PX); - mutable_cell_layer->bounds = grect_inset(cell_layer_bounds, - GEdgeInsets(0, 5, 0, text_left_margin)); + const int text_left_margin = icon_left_margin + MAX(icon_size.w, ATTRIBUTE_ICON_TINY_SIZE_PX); + mutable_cell_layer->bounds = + grect_inset(cell_layer_bounds, GEdgeInsets(0, 5, 0, text_left_margin)); const GFont title_font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); const GFont subtitle_font = system_theme_get_font_for_default_size(TextStyleFont_Caption); @@ -373,7 +502,7 @@ static void prv_draw_notification_cell_rect(GContext *ctx, const Layer *cell_lay //! outer_box is passed as a pointer to save stack space static int16_t prv_draw_centered_text_line_in(GContext *ctx, GFont font, const GRect *outer_box, - const char *text, GAlign align) { + const char *text, GAlign align) { if (!text) { return 0; } @@ -395,7 +524,6 @@ void prv_draw_notification_cell_round(GContext *ctx, const Layer *cell_layer, GR GFont const title_font, const char *title, GFont const subtitle_font, const char *subtitle, GDrawCommandImage *icon) { - if (icon) { GRect icon_rect = (GRect){.size = gdraw_command_image_get_bounds_size(icon)}; @@ -416,8 +544,7 @@ void prv_draw_notification_cell_round(GContext *ctx, const Layer *cell_layer, GR box->origin.y -= 4; if (subtitle) { - box->size.h -= prv_draw_centered_text_line_in(ctx, subtitle_font, box, subtitle, - GAlignBottom); + box->size.h -= prv_draw_centered_text_line_in(ctx, subtitle_font, box, subtitle, GAlignBottom); } if (title) { @@ -462,13 +589,151 @@ static void prv_draw_notification_cell_round_unselected(GContext *ctx, const Lay const GFont font = system_theme_get_font_for_default_size(TextStyleFont_Header); prv_draw_notification_cell_round(ctx, cell_layer, &frame, font, title, NULL, NULL, NULL); } + +// Selected group row on round: reuses prv_draw_list_icon instead of the representative's own +// (often generic) icon, so a group visibly reads as a group. Unselected rows draw no icon. +static void prv_draw_group_notification_cell_round_selected(GContext *ctx, const Layer *cell_layer, + const char *title, + const char *subtitle) { + // as measured from the design specs (same as prv_draw_notification_cell_round_selected) + const int inset = 8; + GRect frame = cell_layer->bounds; + frame.origin.x += inset; + frame.origin.y += inset; + frame.size.h -= inset * 2; + frame.size.w -= inset * 2; + const GFont title_font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); + const GFont subtitle_font = + system_theme_get_font_for_default_size(TextStyleFont_MenuCellSubtitle); + + // Nominal footprint of prv_draw_list_icon's 3 bullet+line rows, used only to position it the + // same way a real icon would be positioned (see prv_draw_notification_cell_round). + const GSize icon_size = GSize(22, 17); + GRect icon_rect = (GRect){.size = icon_size}; + grect_align(&icon_rect, &frame, GAlignTop, true); + icon_rect.origin.y += 4; + + const bool is_highlighted = menu_cell_layer_is_highlighted(cell_layer); + prv_draw_list_icon(ctx, icon_rect.origin, is_highlighted ? GColorWhite : GColorBlack); + + const int16_t icon_space = icon_rect.origin.y + icon_rect.size.h - 12; + frame.origin.y += icon_space; + frame.size.h -= icon_space; + + frame.origin.y -= 4; + if (subtitle) { + frame.size.h -= + prv_draw_centered_text_line_in(ctx, subtitle_font, &frame, subtitle, GAlignBottom); + } + if (title) { + prv_draw_centered_text_line_in(ctx, title_font, &frame, title, GAlignCenter); + } +} #endif -static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, - void *data) { +static time_t prv_get_notification_timestamp(Uuid *id) { + TimelineItem notification = {}; + if (!notification_storage_get(id, ¬ification)) { + return 0; + } + const time_t timestamp = notification.header.timestamp; + timeline_item_free_allocated_buffer(¬ification); + return timestamp; +} + +static bool prv_notifications_same_group(Uuid *id1, Uuid *id2) { + TimelineItem notification1 = {}; + TimelineItem notification2 = {}; + if (!notification_storage_get(id1, ¬ification1) || + !notification_storage_get(id2, ¬ification2)) { + // Free both unconditionally: whichever call failed, the other may still have allocated + // buffers that need releasing (previously only notification1 was freed here). + timeline_item_free_allocated_buffer(¬ification1); + timeline_item_free_allocated_buffer(¬ification2); + return false; + } + + const char *app1 = attribute_get_string(¬ification1.attr_list, AttributeIdAppName, ""); + const char *app2 = attribute_get_string(¬ification2.attr_list, AttributeIdAppName, ""); + const char *title1 = attribute_get_string(¬ification1.attr_list, AttributeIdTitle, ""); + const char *title2 = attribute_get_string(¬ification2.attr_list, AttributeIdTitle, ""); + + const bool app_known_both = !IS_EMPTY_STRING(app1) && !IS_EMPTY_STRING(app2); + const bool same_app = app_known_both && strcmp(app1, app2) == 0; + // Two different, known apps can't be the same group even if their titles happen to match + // (e.g. three "Pebble"-titled notifications with no app name set, then a fourth titled + // "Pebble" but from Discord: that one must NOT join the group). + const bool app_conflict = app_known_both && !same_app; + const bool same_title = !app_conflict && !IS_EMPTY_STRING(title1) && !IS_EMPTY_STRING(title2) && + strcmp(title1, title2) == 0; + + timeline_item_free_allocated_buffer(¬ification1); + timeline_item_free_allocated_buffer(¬ification2); + return same_app || same_title; +} + +// Picks the group's display name: the attribute (title or app name) actually shared by every +// member, rather than just whatever the representative happens to have set. +static const char *prv_get_group_display_name(NotificationNode *notification_list, + Uuid *representative_id, const char *rep_app_name, + const char *rep_title) { + bool title_common = !IS_EMPTY_STRING(rep_title); + bool app_common = !IS_EMPTY_STRING(rep_app_name); + + for (NotificationNode *node = notification_list; node && (title_common || app_common); + node = (NotificationNode *)list_get_next(&node->node)) { + if (!prv_notifications_same_group(representative_id, &node->id)) { + continue; + } + + TimelineItem notification = {}; + if (!notification_storage_get(&node->id, ¬ification)) { + continue; + } + const char *app = attribute_get_string(¬ification.attr_list, AttributeIdAppName, ""); + const char *title = attribute_get_string(¬ification.attr_list, AttributeIdTitle, ""); + + if (title_common && (IS_EMPTY_STRING(title) || strcmp(title, rep_title) != 0)) { + title_common = false; + } + if (app_common && (IS_EMPTY_STRING(app) || strcmp(app, rep_app_name) != 0)) { + app_common = false; + } + + timeline_item_free_allocated_buffer(¬ification); + } + + if (title_common) { + return rep_title; + } + if (app_common) { + return rep_app_name; + } + // Mixed group where no single attribute is common to every member (shouldn't normally happen + // given how the group was formed, but stay defensive): fall back to the previous behavior + // instead of showing an empty name. + return IS_EMPTY_STRING(rep_app_name) ? rep_title : rep_app_name; +} + +// Builds a standalone, disposable list of the notifications sharing a group with +// `representative_id`. Walked tail->head to match prv_push_notification_window_with_list's order. +static NotificationNode *prv_build_group_notification_list(NotificationNode *notification_list, + Uuid *representative_id) { + NotificationNode *group_list = NULL; + NotificationNode *node = (NotificationNode *)list_get_tail((ListNode *)notification_list); + while (node) { + if (prv_notifications_same_group(representative_id, &node->id)) { + prv_notification_list_add_notification_by_id(&group_list, &node->id); + } + node = (NotificationNode *)list_get_prev(&node->node); + } + return group_list; +} + +static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) { NotificationsData *notifications_data = data; - if ((notifications_data->notification_list) && (cell_index->row == 0)) { + if ((notifications_data->notification_list) && (cell_index->row == 0)) { // Clear All button selected prv_settings_clear_history_window_push(notifications_data); return; @@ -477,12 +742,39 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, // shift index since the first one is hard coded to Clear int16_t notif_idx = cell_index->row - 1; - NotificationNode *node = (NotificationNode*) list_get_at( - (ListNode*) notifications_data->notification_list, notif_idx); + NotificationDisplayNode *display_node = (NotificationDisplayNode *)list_get_at( + (ListNode *)notifications_data->display_list, notif_idx); + + if (!display_node) { + return; + } + + NotificationNode *node = prv_find_notification(notifications_data->notification_list, + &display_node->representative_id); + if (!node) { return; } + // Selecting a group: push a temporary filtered list instead of the full notification list, + // so Up/Down stays within the group and Back returns here untouched. + if (display_node->is_group) { + NotificationNode *group_list = prv_build_group_notification_list( + notifications_data->notification_list, &display_node->representative_id); + if (!group_list) { + return; + } + + bool success = prv_push_notification_window_with_list(group_list); + if (success) { + const bool animated = false; + notification_window_focus_notification(&display_node->representative_id, animated); + } + + prv_notification_list_deinit(group_list); + return; + } + bool success = prv_push_notification_window(notifications_data); if (!success) { // Bail if a notification came in ahead of us and created a modal window @@ -496,18 +788,89 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, static uint16_t prv_get_num_rows_callback(struct MenuLayer *menu_layer, uint16_t section_index, void *data) { NotificationsData *notifications_data = data; - NotificationNode *node = notifications_data->notification_list; + NotificationDisplayNode *node = notifications_data->display_list; + // There's no notifications, don't draw anything if (!node) { return 0; } // add one for the CLEAR ALL at the top - return list_count((ListNode *)notifications_data->notification_list) + 1; + return list_count((ListNode *)notifications_data->display_list) + 1; +} + +static uint16_t prv_count_group_notifications(NotificationNode *notification_list, + Uuid *representative_id) { + uint16_t count = 0; + for (NotificationNode *node = notification_list; node; + node = (NotificationNode *)list_get_next(&node->node)) { + if (prv_notifications_same_group(representative_id, &node->id)) { + count++; + } + } + return count; +} + +// `sort_newest_first` is a plain param for now, not read from a pref. +static void prv_rebuild_display_list(NotificationsData *data, bool sort_newest_first) { + prv_display_list_deinit(data->display_list); + data->display_list = NULL; + + NotificationNode *notification_node; + NotificationDisplayNode *display_node; + NotificationDisplayNode *matching_group; + for (notification_node = data->notification_list; notification_node; + notification_node = (NotificationNode *)list_get_next(¬ification_node->node)) { + matching_group = NULL; + const uint16_t group_count = + prv_count_group_notifications(data->notification_list, ¬ification_node->id); + + if (group_count < 3) { + prv_display_list_add(&data->display_list, ¬ification_node->id, false, 1, + prv_get_notification_timestamp(¬ification_node->id)); + continue; + } + + for (display_node = data->display_list; display_node; + display_node = (NotificationDisplayNode *)list_get_next(&display_node->node)) { + if (display_node->is_group && + prv_notifications_same_group(&display_node->representative_id, ¬ification_node->id)) { + matching_group = display_node; + break; + } + } + + if (matching_group) { + continue; + } + + // Starting timestamp only; the sort below guarantees ordering, not traversal order. + prv_display_list_add(&data->display_list, ¬ification_node->id, true, group_count, + prv_get_notification_timestamp(¬ification_node->id)); + } + + prv_display_list_sort(&data->display_list, sort_newest_first); +} + +// Height of a normal row for the current text size. Factored out so group rows match it. +static int16_t prv_get_base_row_height(void) { + const PreferredContentSize runtime_platform_content_size = + system_theme_get_default_content_size_for_runtime_platform(); + return ((int16_t[NumPreferredContentSizes]){ + //! @note this is the same as Medium until Small is designed + [PreferredContentSizeSmall] = + PBL_IF_RECT_ELSE(46, MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT), + [PreferredContentSizeMedium] = + PBL_IF_RECT_ELSE(46, MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT), + [PreferredContentSizeLarge] = menu_cell_basic_cell_height(), + //! @note this is the same as Large until ExtraLarge is designed + [PreferredContentSizeExtraLarge] = menu_cell_basic_cell_height(), + })[runtime_platform_content_size]; } static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell_index, - void *data) { + void *data) { + // Group rows now draw flat, so they use the same height as a regular row. #if PBL_ROUND MenuIndex selected_index = menu_layer_get_selected_index(menu_layer); bool is_selected = menu_index_compare(cell_index, &selected_index) == 0; @@ -519,25 +882,14 @@ static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell return ((DISP_ROWS - STATUS_BAR_LAYER_HEIGHT * 2) - MENU_CELL_ROUND_FOCUSED_TALL_CELL_HEIGHT) / 4; #endif #endif - const PreferredContentSize runtime_platform_content_size = - system_theme_get_default_content_size_for_runtime_platform(); - return ((int16_t[NumPreferredContentSizes]) { - //! @note this is the same as Medium until Small is designed - [PreferredContentSizeSmall] = PBL_IF_RECT_ELSE(46, MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT), - [PreferredContentSizeMedium] = PBL_IF_RECT_ELSE(46, - MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT), - [PreferredContentSizeLarge] = menu_cell_basic_cell_height(), - //! @note this is the same as Large until ExtraLarge is designed - [PreferredContentSizeExtraLarge] = menu_cell_basic_cell_height(), - })[runtime_platform_content_size]; + return prv_get_base_row_height(); } static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) { NotificationsData *notifications_data = data; - void (*draw_cell)(GContext *, const Layer *, const char *, const char *, GDrawCommandImage *) = - PBL_IF_RECT_ELSE(prv_draw_notification_cell_rect, prv_draw_notification_cell_round_selected); + PBL_IF_RECT_ELSE(prv_draw_notification_cell_rect, prv_draw_notification_cell_round_selected); #if PBL_ROUND // on round: just draw the title for anything but the focused row if (!menu_layer_is_index_selected(&s_data->menu_layer, cell_index)) { @@ -554,7 +906,8 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn #else const GFont font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); GRect box = cell_layer->bounds; - box.origin.y += (box.size.h - fonts_get_font_height(font)) / 2 - fonts_get_font_cap_offset(font); + box.origin.y += + (box.size.h - fonts_get_font_height(font)) / 2 - fonts_get_font_cap_offset(font); graphics_draw_text(ctx, i18n_get("Clear All", data), font, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); @@ -565,23 +918,51 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn // shift index since the first one is hard coded to Clear const int16_t notif_idx = cell_index->row - 1; - NotificationNode *node = (NotificationNode*) list_get_at( - (ListNode*) notifications_data->notification_list, notif_idx); + NotificationDisplayNode *display_node = (NotificationDisplayNode *)list_get_at( + (ListNode *)notifications_data->display_list, notif_idx); + if (!display_node) { + return; + } + NotificationNode *node = prv_find_notification(notifications_data->notification_list, + &display_node->representative_id); + if (!node) { return; } - LoadedNotificationNode *loaded_node = prv_loaded_notification_list_load_item( - ¬ifications_data->loaded_notification_list, node); + LoadedNotificationNode *loaded_node = + prv_loaded_notification_list_load_item(¬ifications_data->loaded_notification_list, node); if (!loaded_node) { return; } TimelineItem *notification = &loaded_node->notification; - const char *title = attribute_get_string(¬ification->attr_list, AttributeIdTitle, ""); - const char *subtitle = attribute_get_string(¬ification->attr_list, AttributeIdSubtitle, ""); const char *app_name = attribute_get_string(¬ification->attr_list, AttributeIdAppName, ""); + const char *body = attribute_get_string(¬ification->attr_list, AttributeIdBody, ""); + const char *title = attribute_get_string(¬ification->attr_list, AttributeIdTitle, ""); + const char *subtitle = attribute_get_string(¬ification->attr_list, AttributeIdSubtitle, ""); + // Grouped row: applies to both PBL_RECT and PBL_ROUND (see the two branches below). + if (display_node->is_group) { + const char *group_name = prv_get_group_display_name( + notifications_data->notification_list, &display_node->representative_id, app_name, title); + char group_title[64]; + snprintf(group_title, sizeof(group_title), "%s (%u)", group_name, + (unsigned int)display_node->count); +#if PBL_RECT + prv_draw_group_notification_cell_rect(ctx, cell_layer, group_title, body); +#else // PBL_ROUND + // No dedicated "list" card on round: reuse the standard round cell renderer with + // "(count)" appended to the title. On the selected row, show the list icon instead of + // the representative's own (usually generic) icon. + if (menu_layer_is_index_selected(&s_data->menu_layer, cell_index)) { + prv_draw_group_notification_cell_round_selected(ctx, cell_layer, group_title, body); + } else { + draw_cell(ctx, cell_layer, group_title, body, loaded_node->icon); + } +#endif + return; + } // We show the app name if we don't have a custom icon, otherwise we use the title if (!IS_EMPTY_STRING(app_name) && loaded_node->icon_is_default) { @@ -598,7 +979,7 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn } else { // try to show as much content as possible in title + subtitle title = body; - subtitle = strchr(body, '\n'); // NULL handled gracefully downstream + subtitle = strchr(body, '\n'); // NULL handled gracefully downstream } } else if (IS_EMPTY_STRING(title)) { // no title, but yes subtitle. @@ -620,11 +1001,11 @@ static void prv_update_text_layer_visibility(NotificationsData *data) { // Toggle which layer is visible if (node == NULL) { - layer_set_hidden((Layer *) &data->menu_layer, true); - layer_set_hidden((Layer *) &data->text_layer, false); + layer_set_hidden((Layer *)&data->menu_layer, true); + layer_set_hidden((Layer *)&data->text_layer, false); } else { - layer_set_hidden((Layer *) &data->menu_layer, false); - layer_set_hidden((Layer *) &data->text_layer, true); + layer_set_hidden((Layer *)&data->menu_layer, false); + layer_set_hidden((Layer *)&data->text_layer, true); } } @@ -644,19 +1025,18 @@ static void prv_handle_notification_added(Uuid *id) { return; } - prv_add_notification(s_data, id); + if (prv_find_notification(s_data->notification_list, id)) { + return; + } - // NOTE: To avoid having two flash reads, we only read and validate the notification once. - // We do it here, instead of in the function call below. If the above - // notification_storage validation above is removed, then we should at least validate - // it in the function call below. + prv_add_notification(s_data, id); app_notification_window_add_new_notification_by_id(id); } static void prv_handle_notification(PebbleEvent *e, void *context) { if (e->type == PEBBLE_SYS_NOTIFICATION_EVENT) { Uuid *id = e->sys_notification.notification_id; - switch(e->sys_notification.type) { + switch (e->sys_notification.type) { case NotificationAdded: prv_handle_notification_added(id); break; @@ -668,9 +1048,8 @@ static void prv_handle_notification(PebbleEvent *e, void *context) { break; case NotificationActionResult: { PebbleSysNotificationActionResult *action_result = e->sys_notification.action_result; - if (action_result && - (action_result->type == ActionResultTypeSuccess || - action_result->type == ActionResultTypeSuccessANCSDismiss)) { + if (action_result && (action_result->type == ActionResultTypeSuccess || + action_result->type == ActionResultTypeSuccessANCSDismiss)) { prv_remove_notification(s_data, &action_result->id); app_notification_window_remove_notification_by_id(&action_result->id); } @@ -680,6 +1059,7 @@ static void prv_handle_notification(PebbleEvent *e, void *context) { break; // Not implemented } + prv_rebuild_display_list(s_data, NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT); menu_layer_reload_data(&s_data->menu_layer); prv_update_text_layer_visibility(s_data); } @@ -707,35 +1087,36 @@ static void prv_window_load(Window *window) { const GRect menu_layer_frame = PBL_IF_RECT_ELSE( window->layer.bounds, grect_inset_internal(window->layer.bounds, 0, STATUS_BAR_LAYER_HEIGHT)); menu_layer_init(menu_layer, &menu_layer_frame); - menu_layer_set_callbacks(menu_layer, data, &(MenuLayerCallbacks) { - .get_num_rows = prv_get_num_rows_callback, - .draw_row = prv_draw_row_callback, - .get_cell_height = prv_get_cell_height, - .select_click = prv_select_callback, - }); + menu_layer_set_callbacks(menu_layer, data, + &(MenuLayerCallbacks){ + .get_num_rows = prv_get_num_rows_callback, + .draw_row = prv_draw_row_callback, + .get_cell_height = prv_get_cell_height, + .select_click = prv_select_callback, + }); menu_layer_set_normal_colors(menu_layer, GColorWhite, GColorBlack); - menu_layer_set_highlight_colors(menu_layer, - PBL_IF_COLOR_ELSE(DEFAULT_NOTIFICATION_COLOR, GColorBlack), - GColorWhite); + menu_layer_set_highlight_colors( + menu_layer, PBL_IF_COLOR_ELSE(DEFAULT_NOTIFICATION_COLOR, GColorBlack), GColorWhite); menu_layer_set_click_config_onto_window(menu_layer, window); menu_layer_set_scroll_wrap_around(menu_layer, shell_prefs_get_menu_scroll_wrap_around_enable()); - menu_layer_set_scroll_vibe_on_wrap(menu_layer, shell_prefs_get_menu_scroll_vibe_behavior() == MenuScrollVibeOnWrapAround); - menu_layer_set_scroll_vibe_on_blocked(menu_layer, shell_prefs_get_menu_scroll_vibe_behavior() == MenuScrollVibeOnLocked); + menu_layer_set_scroll_vibe_on_wrap( + menu_layer, shell_prefs_get_menu_scroll_vibe_behavior() == MenuScrollVibeOnWrapAround); + menu_layer_set_scroll_vibe_on_blocked( + menu_layer, shell_prefs_get_menu_scroll_vibe_behavior() == MenuScrollVibeOnLocked); layer_add_child(&window->layer, menu_layer_get_layer(menu_layer)); TextLayer *text_layer = &data->text_layer; const int16_t horizontal_margin = 5; const GFont font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle); // configure text layer to be vertically aligned (15 is hacking around our poor fonts) - text_layer_init_with_parameters(text_layer, - &GRect(horizontal_margin, window->layer.bounds.size.h / 2 - 15, - window->layer.bounds.size.w - horizontal_margin, - window->layer.bounds.size.h / 2), - i18n_get("No notifications", data), font, GColorBlack, - GColorWhite, GTextAlignmentCenter, - GTextOverflowModeTrailingEllipsis); + text_layer_init_with_parameters( + text_layer, + &GRect(horizontal_margin, window->layer.bounds.size.h / 2 - 15, + window->layer.bounds.size.w - horizontal_margin, window->layer.bounds.size.h / 2), + i18n_get("No notifications", data), font, GColorBlack, GColorWhite, GTextAlignmentCenter, + GTextOverflowModeTrailingEllipsis); layer_add_child(&window->layer, text_layer_get_layer(text_layer)); #if PBL_ROUND @@ -756,11 +1137,11 @@ static void prv_push_window(NotificationsData *data) { Window *window = &data->window; window_init(window, WINDOW_NAME("Notifications")); window_set_user_data(window, data); - window_set_window_handlers(window, &(WindowHandlers) { - .load = prv_window_load, - .appear = prv_window_appear, - .disappear = prv_window_disappear, - }); + window_set_window_handlers(window, &(WindowHandlers){ + .load = prv_window_load, + .appear = prv_window_appear, + .disappear = prv_window_disappear, + }); const bool animated = true; app_window_stack_push(window, animated); @@ -774,12 +1155,13 @@ static void prv_handle_init(void) { app_state_set_user_data(data); - data->notification_event_info = (EventServiceInfo) { - .type = PEBBLE_SYS_NOTIFICATION_EVENT, - .handler = prv_handle_notification, + data->notification_event_info = (EventServiceInfo){ + .type = PEBBLE_SYS_NOTIFICATION_EVENT, + .handler = prv_handle_notification, }; event_service_client_subscribe(&data->notification_event_info); prv_load_notification_storage(data); + prv_rebuild_display_list(data, NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT); prv_push_window(data); } @@ -791,6 +1173,7 @@ static void prv_handle_deinit(void) { #endif menu_layer_deinit(&data->menu_layer); event_service_client_unsubscribe(&data->notification_event_info); + prv_display_list_deinit(data->display_list); prv_loaded_notification_list_deinit(data->loaded_notification_list); prv_notification_list_deinit(data->notification_list); @@ -831,29 +1214,30 @@ static void prv_clear_history_main(void) { prv_clear_history_handle_deinit(); } - -const PebbleProcessMd* notifications_app_get_info() { +const PebbleProcessMd *notifications_app_get_info() { static const PebbleProcessMdSystem s_app_md = { - .common = { - .main_func = prv_s_main, - // UUID: b2cae818-10f8-46df-ad2b-98ad2254a3c1 - .uuid = {0xb2, 0xca, 0xe8, 0x18, 0x10, 0xf8, 0x46, 0xdf, - 0xad, 0x2b, 0x98, 0xad, 0x22, 0x54, 0xa3, 0xc1}, - }, - .name = i18n_noop("Notifications"), - .icon_resource_id = RESOURCE_ID_NOTIFICATIONS_APP_GLANCE, + .common = + { + .main_func = prv_s_main, + // UUID: b2cae818-10f8-46df-ad2b-98ad2254a3c1 + .uuid = {0xb2, 0xca, 0xe8, 0x18, 0x10, 0xf8, 0x46, 0xdf, 0xad, 0x2b, 0x98, 0xad, 0x22, + 0x54, 0xa3, 0xc1}, + }, + .name = i18n_noop("Notifications"), + .icon_resource_id = RESOURCE_ID_NOTIFICATIONS_APP_GLANCE, }; - return (const PebbleProcessMd*) &s_app_md; + return (const PebbleProcessMd *)&s_app_md; } const PebbleProcessMd *notifications_clear_history_app_get_info(void) { static const PebbleProcessMdSystem s_app_md = { - .common = { - .main_func = prv_clear_history_main, - .uuid = NOTIFICATIONS_CLEAR_HISTORY_UUID, - .visibility = ProcessVisibilityQuickLaunch, - }, - .name = i18n_noop("Clear Notification History"), + .common = + { + .main_func = prv_clear_history_main, + .uuid = NOTIFICATIONS_CLEAR_HISTORY_UUID, + .visibility = ProcessVisibilityQuickLaunch, + }, + .name = i18n_noop("Clear Notification History"), }; - return (const PebbleProcessMd *) &s_app_md; -} + return (const PebbleProcessMd *)&s_app_md; +} \ No newline at end of file From 85f1458202cc1c6d27c6de42a6952ea4ba9beefd Mon Sep 17 00:00:00 2001 From: Eliryen Date: Mon, 31 Aug 2026 19:26:20 +0200 Subject: [PATCH 2/2] (env 50% AI-generated) notifications: add better grouped notification views Add notification sorting and grouping preferences, localized settings strings for all supported languages, and a better separate animated window for viewing notifications within a group. Co-authored-by: GPT-5 --- .../alerts_preferences_private.h | 13 +- resources/normal/base/lang/ca_ES/tintin.po | 21 ++ resources/normal/base/lang/de_DE/tintin.po | 12 + resources/normal/base/lang/en_TW/tintin.po | 13 + resources/normal/base/lang/es_ES/tintin.po | 22 ++ resources/normal/base/lang/fr_FR/tintin.po | 20 ++ resources/normal/base/lang/it_IT/tintin.po | 21 ++ resources/normal/base/lang/ja_JP/tintin.po | 23 ++ resources/normal/base/lang/nl_NL/tintin.po | 23 ++ resources/normal/base/lang/pl_PL/tintin.po | 19 ++ resources/normal/base/lang/pt_PT/tintin.po | 21 ++ resources/normal/base/lang/ru_RU/tintin.po | 20 ++ resources/normal/base/lang/tintin.pot | 23 ++ resources/normal/base/lang/uk-UA/tintin.po | 20 ++ src/fw/apps/system/notifications.c | 238 +++++++++++++++--- src/fw/apps/system/settings/notifications.c | 32 +++ .../notifications/alerts_preferences.c | 31 ++- 17 files changed, 539 insertions(+), 33 deletions(-) diff --git a/include/pbl/services/notifications/alerts_preferences_private.h b/include/pbl/services/notifications/alerts_preferences_private.h index 6072cea648..6f14aef3a5 100644 --- a/include/pbl/services/notifications/alerts_preferences_private.h +++ b/include/pbl/services/notifications/alerts_preferences_private.h @@ -15,6 +15,13 @@ #define NOTIF_WINDOW_TIMEOUT_INFINITE ((uint32_t)~0) #define NOTIF_WINDOW_TIMEOUT_DEFAULT (3 * MS_PER_MINUTE) +typedef enum { + NotificationSortNewestFirst = 0, + NotificationSortOldestFirst, + NotificationSortAlphabetical, + NotificationSortModeCount, +} NotificationSortMode; + void alerts_preferences_init(void); AlertMask alerts_preferences_get_alert_mask(void); @@ -52,6 +59,11 @@ NotificationStatusBarStyle alerts_preferences_get_notification_status_bar_style( void alerts_preferences_set_notification_status_bar_style(NotificationStatusBarStyle style); +NotificationSortMode alerts_preferences_get_notification_sort_mode(void); +void alerts_preferences_set_notification_sort_mode(NotificationSortMode mode); +bool alerts_preferences_get_notification_grouping(void); +void alerts_preferences_set_notification_grouping(bool enable); + bool alerts_preferences_get_vibrate(void); void alerts_preferences_set_vibrate(bool enable); @@ -93,4 +105,3 @@ void alerts_preferences_unlock(void); //! new value that was placed into the backing store. //! @param[in] event pointer to the blob DB event void alerts_preferences_handle_blob_db_event(PebbleBlobDBEvent *event); - diff --git a/resources/normal/base/lang/ca_ES/tintin.po b/resources/normal/base/lang/ca_ES/tintin.po index 59c1123543..3deff2135f 100644 --- a/resources/normal/base/lang/ca_ES/tintin.po +++ b/resources/normal/base/lang/ca_ES/tintin.po @@ -1246,6 +1246,27 @@ msgstr "Activat" msgid "Off" msgstr "Desactivat" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Més recents primer" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Més antics primer" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabètic" +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Ordre de classificació" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Agrupa les notificacions" + + #: ../src/fw/apps/system/settings/display.c:461 msgid "Ambient Sensor" msgstr "Sensor ambiental" diff --git a/resources/normal/base/lang/de_DE/tintin.po b/resources/normal/base/lang/de_DE/tintin.po index 55f6e47083..318719e7bb 100644 --- a/resources/normal/base/lang/de_DE/tintin.po +++ b/resources/normal/base/lang/de_DE/tintin.po @@ -482,6 +482,18 @@ msgstr "Start" msgid "End" msgstr "Ende" +msgid "Newest first" +msgstr "Neueste zuerst" +msgid "Oldest first" +msgstr "Älteste zuerst" +msgid "Alphabetical" +msgstr "Alphabetisch" +msgid "Sort Order" +msgstr "Sortierreihenfolge" +msgid "Group Notifications" +msgstr "Benachrichtigungen gruppieren" + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Diktieren nicht verfügbar." diff --git a/resources/normal/base/lang/en_TW/tintin.po b/resources/normal/base/lang/en_TW/tintin.po index a8b4272c2b..4c215a2c25 100644 --- a/resources/normal/base/lang/en_TW/tintin.po +++ b/resources/normal/base/lang/en_TW/tintin.po @@ -482,6 +482,19 @@ msgstr "Start" msgid "End" msgstr "End" +msgid "Newest first" +msgstr "Newest first" +msgid "Oldest first" +msgstr "Oldest first" +msgid "Alphabetical" +msgstr "Alphabetical" +msgid "Sort Order" +msgstr "Sort Order" +msgid "Group Notifications" +msgstr "Group Notifications" + + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dictation is not available." diff --git a/resources/normal/base/lang/es_ES/tintin.po b/resources/normal/base/lang/es_ES/tintin.po index ed02f63dd3..9c76012643 100644 --- a/resources/normal/base/lang/es_ES/tintin.po +++ b/resources/normal/base/lang/es_ES/tintin.po @@ -482,6 +482,28 @@ msgstr "Comienzo" msgid "End" msgstr "Final" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Más recientes primero" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Más antiguos primero" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabético" +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Orden de clasificación" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Agrupar notificaciones" + + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dictado no disponible." diff --git a/resources/normal/base/lang/fr_FR/tintin.po b/resources/normal/base/lang/fr_FR/tintin.po index eed43fa1b2..07126c44f2 100644 --- a/resources/normal/base/lang/fr_FR/tintin.po +++ b/resources/normal/base/lang/fr_FR/tintin.po @@ -481,6 +481,26 @@ msgstr "Début" msgid "End" msgstr "Fin" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Plus récentes en premier" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Plus anciennes en premier" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alphabétique" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Ordre de tri" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Regrouper les notifications" + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dictation vocale indisponible" diff --git a/resources/normal/base/lang/it_IT/tintin.po b/resources/normal/base/lang/it_IT/tintin.po index a6cc508e53..2bc152e2b1 100644 --- a/resources/normal/base/lang/it_IT/tintin.po +++ b/resources/normal/base/lang/it_IT/tintin.po @@ -482,6 +482,27 @@ msgstr "Avvia" msgid "End" msgstr "Fine" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Più recenti prima" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Più vecchie prima" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabetico" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Ordine di ordinamento" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Raggruppa notifiche" + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dettatura non disponibile" diff --git a/resources/normal/base/lang/ja_JP/tintin.po b/resources/normal/base/lang/ja_JP/tintin.po index a08fd8f213..059c99867f 100644 --- a/resources/normal/base/lang/ja_JP/tintin.po +++ b/resources/normal/base/lang/ja_JP/tintin.po @@ -480,6 +480,29 @@ msgstr "開始" msgid "End" msgstr "終了" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "新しい順" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "古い順" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "アルファベット順" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "並び順" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "通知をグループ化" + + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "音声入力は利用できません。" diff --git a/resources/normal/base/lang/nl_NL/tintin.po b/resources/normal/base/lang/nl_NL/tintin.po index 038bce9ca6..abde522c01 100644 --- a/resources/normal/base/lang/nl_NL/tintin.po +++ b/resources/normal/base/lang/nl_NL/tintin.po @@ -481,6 +481,29 @@ msgstr "Start" msgid "End" msgstr "End" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Nieuwste eerst" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Oudste eerst" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabetisch" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Sorteervolgorde" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Meldingen groeperen" + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dictation is not available." diff --git a/resources/normal/base/lang/pl_PL/tintin.po b/resources/normal/base/lang/pl_PL/tintin.po index d8910b0dbc..4438eb7588 100644 --- a/resources/normal/base/lang/pl_PL/tintin.po +++ b/resources/normal/base/lang/pl_PL/tintin.po @@ -484,6 +484,25 @@ msgstr "Start" msgid "End" msgstr "Koniec" + #: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Najnowsze najpierw" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Najstarsze najpierw" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabetycznie" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Kolejność sortowania" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Grupuj powiadomienia" + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dyktowanie jest niedostępne." diff --git a/resources/normal/base/lang/pt_PT/tintin.po b/resources/normal/base/lang/pt_PT/tintin.po index da5ed61785..a93e555b9a 100644 --- a/resources/normal/base/lang/pt_PT/tintin.po +++ b/resources/normal/base/lang/pt_PT/tintin.po @@ -482,6 +482,27 @@ msgstr "Inicio" msgid "End" msgstr "Fim" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Mais recentes primeiro" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Mais antigas primeiro" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alfabético" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Ordem de classificação" +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Agrupar notificações" + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Ditado não disponível." diff --git a/resources/normal/base/lang/ru_RU/tintin.po b/resources/normal/base/lang/ru_RU/tintin.po index ce0339b311..fb3686e6b2 100644 --- a/resources/normal/base/lang/ru_RU/tintin.po +++ b/resources/normal/base/lang/ru_RU/tintin.po @@ -482,6 +482,26 @@ msgstr "Start" msgid "End" msgstr "End" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Newest first" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Oldest first" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alphabetical" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Sort Order" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Group Notifications" + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Dictation is not available." diff --git a/resources/normal/base/lang/tintin.pot b/resources/normal/base/lang/tintin.pot index e3e1e5cdd9..70f3c0cd0b 100644 --- a/resources/normal/base/lang/tintin.pot +++ b/resources/normal/base/lang/tintin.pot @@ -585,6 +585,29 @@ msgstr "" msgid "End" msgstr "" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Newest firs" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Oldest first" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "Alphabetical" + +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Sort Order" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Group Notifications" + + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "" diff --git a/resources/normal/base/lang/uk-UA/tintin.po b/resources/normal/base/lang/uk-UA/tintin.po index c32ef612f8..1749fb6319 100644 --- a/resources/normal/base/lang/uk-UA/tintin.po +++ b/resources/normal/base/lang/uk-UA/tintin.po @@ -476,6 +476,26 @@ msgstr "Початок" msgid "End" msgstr "Кінець" +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Newest first" +msgstr "Спочатку новіші" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Oldest first" +msgstr "Спочатку старіші" + +#: ../src/fw/apps/system/settings/notifications.c:54 +msgid "Alphabetical" +msgstr "За алфавітом" +#: ../src/fw/apps/system/settings/notifications.c:64 +#: ../src/fw/apps/system/settings/notifications.c:309 +msgid "Sort Order" +msgstr "Порядок сортування" + +#: ../src/fw/apps/system/settings/notifications.c:313 +msgid "Group Notifications" +msgstr "Групувати сповіщення" + #: ../src/fw/applib/voice/voice_window.c:200 msgid "Dictation is not available." msgstr "Диктування не доступне." diff --git a/src/fw/apps/system/notifications.c b/src/fw/apps/system/notifications.c index 6fd6510a62..b4fccd6773 100644 --- a/src/fw/apps/system/notifications.c +++ b/src/fw/apps/system/notifications.c @@ -25,6 +25,7 @@ #include "process_state/app_state/app_state.h" #include "resource/resource_ids.auto.h" #include "pbl/services/i18n/i18n.h" +#include "pbl/services/notifications/alerts_preferences_private.h" #include "pbl/services/blob_db/pin_db.h" #include "pbl/services/notifications/notification_storage.h" #include "pbl/services/timeline/notification_layout.h" @@ -46,6 +47,9 @@ typedef struct NotificationNode { ListNode node; Uuid id; } NotificationNode; + +#define NOTIFICATION_SORT_KEY_MAX_LEN 32 + typedef struct NotificationDisplayNode { ListNode node; bool is_group; @@ -53,16 +57,21 @@ typedef struct NotificationDisplayNode { uint16_t count; // Timestamp used to sort this row (for a group, this is its most recent member's timestamp). time_t timestamp; + // Title (or app name, if no title) used as the key for alphabetical sorting. + char sort_key[NOTIFICATION_SORT_KEY_MAX_LEN]; } NotificationDisplayNode; typedef struct NotificationsData { Window window; MenuLayer menu_layer; TextLayer text_layer; NotificationNode *notification_list; + bool group_view; NotificationDisplayNode *display_list; LoadedNotificationNode *loaded_notification_list; EventServiceInfo notification_event_info; ActionableDialog *actionable_dialog; + // Zero-initialized by app_zalloc_check, so this defaults to NotificationSortNewestFirst. + NotificationSortMode sort_mode; #if PBL_ROUND StatusBarLayer status_bar_layer; #endif @@ -70,15 +79,22 @@ typedef struct NotificationsData { static NotificationsData *s_data = NULL; +static void prv_update_text_layer_visibility(NotificationsData *data); +static void prv_rebuild_display_list(NotificationsData *data); +static void prv_push_window(NotificationsData *data); + + static NotificationDisplayNode *prv_display_list_add(NotificationDisplayNode **display_list, Uuid *id, bool is_group, uint16_t count, - time_t timestamp) { + time_t timestamp, const char *sort_key) { NotificationDisplayNode *new_node = app_malloc_check(sizeof(NotificationDisplayNode)); list_init((ListNode *)new_node); new_node->is_group = is_group; new_node->representative_id = *id; new_node->count = count; new_node->timestamp = timestamp; + strncpy(new_node->sort_key, sort_key ? sort_key : "", NOTIFICATION_SORT_KEY_MAX_LEN - 1); + new_node->sort_key[NOTIFICATION_SORT_KEY_MAX_LEN - 1] = '\0'; *display_list = (NotificationDisplayNode *)list_prepend((ListNode *)*display_list, (ListNode *)new_node); return new_node; @@ -92,10 +108,33 @@ static void prv_display_list_deinit(NotificationDisplayNode *display_list) { } } -// Sorts the display list in place by timestamp, fixing incorrect ordering of both single -// and grouped rows. `sort_newest_first` stays a plain parameter until wired to a real setting. +// Plain byte-by-byte, ASCII-only case-insensitive comparison - not real Unicode collation. +// Sorting titles that mix Latin, Cyrillic, CJK, etc. "correctly" would need per-locale collation +// tables we don't have room for on-watch. This keeps the common Latin "A-Z" case properly +// ordered and just falls back to a stable byte-value ordering for anything else, instead of +// trying (and failing) to offer per-alphabet sorting. +static int prv_sort_key_casecmp(const char *a, const char *b) { + while (*a && *b) { + char ca = *a; + char cb = *b; + if (ca >= 'a' && ca <= 'z') { + ca = (char)(ca - ('a' - 'A')); + } + if (cb >= 'a' && cb <= 'z') { + cb = (char)(cb - ('a' - 'A')); + } + if (ca != cb) { + return (unsigned char)ca - (unsigned char)cb; + } + a++; + b++; + } + return (unsigned char)*a - (unsigned char)*b; +} + +// Sorts the display list in place, fixing incorrect ordering of both single and grouped rows. static void prv_display_list_sort(NotificationDisplayNode **display_list, - bool sort_newest_first) { + NotificationSortMode sort_mode) { const uint32_t count = list_count((ListNode *)*display_list); if (count < 2) { return; @@ -112,8 +151,19 @@ static void prv_display_list_sort(NotificationDisplayNode **display_list, NotificationDisplayNode *key = nodes[i]; int32_t j = (int32_t)i - 1; while (j >= 0) { - const bool should_shift = sort_newest_first ? (nodes[j]->timestamp < key->timestamp) - : (nodes[j]->timestamp > key->timestamp); + bool should_shift; + switch (sort_mode) { + case NotificationSortOldestFirst: + should_shift = (nodes[j]->timestamp > key->timestamp); + break; + case NotificationSortAlphabetical: + should_shift = (prv_sort_key_casecmp(nodes[j]->sort_key, key->sort_key) > 0); + break; + case NotificationSortNewestFirst: + default: + should_shift = (nodes[j]->timestamp < key->timestamp); + break; + } if (!should_shift) { break; } @@ -134,11 +184,6 @@ static void prv_display_list_sort(NotificationDisplayNode **display_list, app_free(nodes); } -// Default sort direction: true = most recent notification at the top (normal behavior). -// Deliberately not read from shell_prefs or any settings store yet - swap this constant for a -// real preference getter later and every call site below will pick it up automatically. -static const bool NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT = true; - static const unsigned int MAX_ACTIVE_NOTIFICATIONS = 6; static bool prv_loaded_notification_list_filter_cb(ListNode *node, void *data) { @@ -756,8 +801,8 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, vo return; } - // Selecting a group: push a temporary filtered list instead of the full notification list, - // so Up/Down stays within the group and Back returns here untouched. + // Selecting a group switches the main Notifications list to a filtered view. This keeps the + // normal list UI, instead of opening the notification detail window. if (display_node->is_group) { NotificationNode *group_list = prv_build_group_notification_list( notifications_data->notification_list, &display_node->representative_id); @@ -765,13 +810,12 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, vo return; } - bool success = prv_push_notification_window_with_list(group_list); - if (success) { - const bool animated = false; - notification_window_focus_notification(&display_node->representative_id, animated); - } - - prv_notification_list_deinit(group_list); + NotificationsData *group_data = app_zalloc_check(sizeof(NotificationsData)); + group_data->notification_list = group_list; + group_data->group_view = true; + group_data->sort_mode = notifications_data->sort_mode; + prv_rebuild_display_list(group_data); + prv_push_window(group_data); return; } @@ -811,11 +855,29 @@ static uint16_t prv_count_group_notifications(NotificationNode *notification_lis return count; } -// `sort_newest_first` is a plain param for now, not read from a pref. -static void prv_rebuild_display_list(NotificationsData *data, bool sort_newest_first) { +// Best-effort label to alphabetize on: the notification's title if it has one, otherwise its +// app name. For a group entry this uses the representative's own title/app name rather than +// reproducing prv_get_group_display_name's "common to every member" logic - good enough for +// sorting, without re-walking the whole notification list for every row on every rebuild. +static void prv_get_notification_sort_key(Uuid *id, char *out, size_t out_size) { + out[0] = '\0'; + TimelineItem notification = {}; + if (!notification_storage_get(id, ¬ification)) { + return; + } + const char *title = attribute_get_string(¬ification.attr_list, AttributeIdTitle, ""); + const char *app_name = attribute_get_string(¬ification.attr_list, AttributeIdAppName, ""); + const char *key = !IS_EMPTY_STRING(title) ? title : app_name; + strncpy(out, key, out_size - 1); + out[out_size - 1] = '\0'; + timeline_item_free_allocated_buffer(¬ification); +} + +static void prv_rebuild_display_list(NotificationsData *data) { prv_display_list_deinit(data->display_list); data->display_list = NULL; + char sort_key[NOTIFICATION_SORT_KEY_MAX_LEN]; NotificationNode *notification_node; NotificationDisplayNode *display_node; NotificationDisplayNode *matching_group; @@ -825,9 +887,10 @@ static void prv_rebuild_display_list(NotificationsData *data, bool sort_newest_f const uint16_t group_count = prv_count_group_notifications(data->notification_list, ¬ification_node->id); - if (group_count < 3) { + if (data->group_view || !alerts_preferences_get_notification_grouping() || group_count < 3) { + prv_get_notification_sort_key(¬ification_node->id, sort_key, sizeof(sort_key)); prv_display_list_add(&data->display_list, ¬ification_node->id, false, 1, - prv_get_notification_timestamp(¬ification_node->id)); + prv_get_notification_timestamp(¬ification_node->id), sort_key); continue; } @@ -845,13 +908,109 @@ static void prv_rebuild_display_list(NotificationsData *data, bool sort_newest_f } // Starting timestamp only; the sort below guarantees ordering, not traversal order. + prv_get_notification_sort_key(¬ification_node->id, sort_key, sizeof(sort_key)); prv_display_list_add(&data->display_list, ¬ification_node->id, true, group_count, - prv_get_notification_timestamp(¬ification_node->id)); + prv_get_notification_timestamp(¬ification_node->id), sort_key); } - prv_display_list_sort(&data->display_list, sort_newest_first); + prv_display_list_sort(&data->display_list, data->sort_mode); } +// Legacy long-press sort menu disabled; settings app now owns these controls. +#if 0 +/////////////////// +// Sort menu (long press SELECT) + +typedef struct SortMenuData { + Window window; + MenuLayer menu_layer; + NotificationsData *notifications_data; +} SortMenuData; + +static const char *prv_sort_mode_get_label(NotificationsSortMode sort_mode, void *i18n_owner) { + switch (sort_mode) { + case NotificationsSortOldestFirst: + return i18n_get("Plus anciennes d'abord", i18n_owner); + case NotificationsSortAlphabetical: + return i18n_get("Ordre alphabetique", i18n_owner); + case NotificationsSortNewestFirst: + default: + return i18n_get("Plus recentes d'abord", i18n_owner); + } +} + +static uint16_t prv_sort_menu_get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, + void *context) { + return NotificationsSortModeCount; +} + +static void prv_sort_menu_draw_row_callback(GContext *ctx, const Layer *cell_layer, + MenuIndex *cell_index, void *context) { + SortMenuData *sort_menu_data = context; + const NotificationSortMode row_sort_mode = (NotificationSortMode)cell_index->row; + const char *title = prv_sort_mode_get_label(row_sort_mode, sort_menu_data); + const bool is_current = (row_sort_mode == sort_menu_data->notifications_data->sort_mode); + menu_cell_basic_draw(ctx, cell_layer, title, + is_current ? i18n_get("Tri actuel", sort_menu_data) : NULL, NULL); +} + +static void prv_sort_menu_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, + void *context) { + SortMenuData *sort_menu_data = context; + NotificationsData *notifications_data = sort_menu_data->notifications_data; + + notifications_data->sort_mode = (NotificationSortMode)cell_index->row; + prv_rebuild_display_list(notifications_data); + menu_layer_reload_data(¬ifications_data->menu_layer); + + const bool animated = true; + app_window_stack_pop(animated); +} + +static void prv_sort_menu_window_load(Window *window) { + SortMenuData *sort_menu_data = window_get_user_data(window); + MenuLayer *menu_layer = &sort_menu_data->menu_layer; + + menu_layer_init(menu_layer, &window->layer.bounds); + menu_layer_set_callbacks(menu_layer, sort_menu_data, + &(MenuLayerCallbacks){ + .get_num_rows = prv_sort_menu_get_num_rows_callback, + .draw_row = prv_sort_menu_draw_row_callback, + .select_click = prv_sort_menu_select_callback, + }); + menu_layer_set_click_config_onto_window(menu_layer, window); + menu_layer_set_selected_index( + menu_layer, MenuIndex(0, (uint16_t)sort_menu_data->notifications_data->sort_mode), + MenuRowAlignCenter, false); + layer_add_child(&window->layer, menu_layer_get_layer(menu_layer)); +} + +static void prv_sort_menu_window_unload(Window *window) { + SortMenuData *sort_menu_data = window_get_user_data(window); + menu_layer_deinit(&sort_menu_data->menu_layer); + i18n_free_all(sort_menu_data); + window_deinit(window); + app_free(sort_menu_data); +} + +static void prv_push_sort_menu_window(NotificationsData *notifications_data) { + SortMenuData *sort_menu_data = app_malloc_check(sizeof(SortMenuData)); + sort_menu_data->notifications_data = notifications_data; + + Window *window = &sort_menu_data->window; + window_init(window, WINDOW_NAME("Sort Notifications")); + window_set_user_data(window, sort_menu_data); + window_set_window_handlers(window, &(WindowHandlers){ + .load = prv_sort_menu_window_load, + .unload = prv_sort_menu_window_unload, + }); + + const bool animated = true; + app_window_stack_push(window, animated); +} +#endif + +// Long press on SELECT opens the sort menu. Nothing to sort when the list is empty. // Height of a normal row for the current text size. Factored out so group rows match it. static int16_t prv_get_base_row_height(void) { const PreferredContentSize runtime_platform_content_size = @@ -892,7 +1051,7 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn PBL_IF_RECT_ELSE(prv_draw_notification_cell_rect, prv_draw_notification_cell_round_selected); #if PBL_ROUND // on round: just draw the title for anything but the focused row - if (!menu_layer_is_index_selected(&s_data->menu_layer, cell_index)) { + if (!menu_layer_is_index_selected(¬ifications_data->menu_layer, cell_index)) { draw_cell = prv_draw_notification_cell_round_unselected; } #endif @@ -955,7 +1114,7 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn // No dedicated "list" card on round: reuse the standard round cell renderer with // "(count)" appended to the title. On the selected row, show the list icon instead of // the representative's own (usually generic) icon. - if (menu_layer_is_index_selected(&s_data->menu_layer, cell_index)) { + if (menu_layer_is_index_selected(¬ifications_data->menu_layer, cell_index)) { prv_draw_group_notification_cell_round_selected(ctx, cell_layer, group_title, body); } else { draw_cell(ctx, cell_layer, group_title, body, loaded_node->icon); @@ -1059,7 +1218,7 @@ static void prv_handle_notification(PebbleEvent *e, void *context) { break; // Not implemented } - prv_rebuild_display_list(s_data, NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT); + prv_rebuild_display_list(s_data); menu_layer_reload_data(&s_data->menu_layer); prv_update_text_layer_visibility(s_data); } @@ -1081,6 +1240,21 @@ static void prv_window_disappear(Window *window) { data->loaded_notification_list = NULL; } +static void prv_window_unload(Window *window) { + NotificationsData *data = window_get_user_data(window); + if (!data->group_view) { + return; + } +#if PBL_ROUND + status_bar_layer_deinit(&data->status_bar_layer); +#endif + menu_layer_deinit(&data->menu_layer); + prv_display_list_deinit(data->display_list); + prv_notification_list_deinit(data->notification_list); + i18n_free_all(data); + app_free(data); +} + static void prv_window_load(Window *window) { NotificationsData *data = window_get_user_data(window); MenuLayer *menu_layer = &data->menu_layer; @@ -1141,6 +1315,7 @@ static void prv_push_window(NotificationsData *data) { .load = prv_window_load, .appear = prv_window_appear, .disappear = prv_window_disappear, + .unload = prv_window_unload, }); const bool animated = true; @@ -1161,7 +1336,8 @@ static void prv_handle_init(void) { }; event_service_client_subscribe(&data->notification_event_info); prv_load_notification_storage(data); - prv_rebuild_display_list(data, NOTIFICATIONS_SORT_NEWEST_FIRST_DEFAULT); + data->sort_mode = alerts_preferences_get_notification_sort_mode(); + prv_rebuild_display_list(data); prv_push_window(data); } @@ -1240,4 +1416,4 @@ const PebbleProcessMd *notifications_clear_history_app_get_info(void) { .name = i18n_noop("Clear Notification History"), }; return (const PebbleProcessMd *)&s_app_md; -} \ No newline at end of file +} diff --git a/src/fw/apps/system/settings/notifications.c b/src/fw/apps/system/settings/notifications.c index b65cd0948a..042640e635 100644 --- a/src/fw/apps/system/settings/notifications.c +++ b/src/fw/apps/system/settings/notifications.c @@ -38,6 +38,8 @@ typedef struct { enum NotificationsItem { NotificationsItemFilter, + NotificationsItemSortOrder, + NotificationsItemGrouping, NotificationsItemWindowTimeout, #if PBL_BW NotificationsItemDesignStyle, @@ -48,6 +50,22 @@ enum NotificationsItem { NotificationsItem_Count, }; +static const char *s_sort_labels[] = { + i18n_noop("Newest first"), i18n_noop("Oldest first"), i18n_noop("Alphabetical") +}; + +static void prv_sort_select(OptionMenu *menu, int selection, void *context) { + alerts_preferences_set_notification_sort_mode((NotificationSortMode)selection); + app_window_stack_remove(&menu->window, true); +} + +static void prv_sort_push(SettingsNotificationsData *data) { + const OptionMenuCallbacks callbacks = {.select = prv_sort_select}; + settings_option_menu_push(i18n_noop("Sort Order"), OptionMenuContentType_SingleLine, + alerts_preferences_get_notification_sort_mode(), &callbacks, + ARRAY_LENGTH(s_sort_labels), true, s_sort_labels, data); +} + // Filter Alerts ////////////////////////// @@ -287,6 +305,14 @@ static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx, title = i18n_noop("Filter"); subtitle = prv_alert_mask_to_label(alerts_get_mask()); break; + case NotificationsItemSortOrder: + title = i18n_noop("Sort Order"); + subtitle = s_sort_labels[alerts_preferences_get_notification_sort_mode()]; + break; + case NotificationsItemGrouping: + title = i18n_noop("Group Notifications"); + subtitle = alerts_preferences_get_notification_grouping() ? i18n_noop("On") : i18n_noop("Off"); + break; case NotificationsItemWindowTimeout: { /// String within Settings->Notifications that describes the window timeout setting title = i18n_noop("Timeout"); @@ -340,6 +366,12 @@ static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) { case NotificationsItemFilter: prv_filter_menu_push(data); break; + case NotificationsItemSortOrder: + prv_sort_push(data); + break; + case NotificationsItemGrouping: + alerts_preferences_set_notification_grouping(!alerts_preferences_get_notification_grouping()); + break; case NotificationsItemWindowTimeout: prv_window_timeout_menu_push(data); break; diff --git a/src/fw/services/notifications/alerts_preferences.c b/src/fw/services/notifications/alerts_preferences.c index e020b93d9b..3dd456952e 100644 --- a/src/fw/services/notifications/alerts_preferences.c +++ b/src/fw/services/notifications/alerts_preferences.c @@ -97,6 +97,12 @@ static bool s_notification_backlight = true; // true = enable backlight (defaul #define PREF_KEY_NOTIF_STATUS_BAR_STYLE "notifStatusBarStyle" static NotificationStatusBarStyle s_notification_status_bar_style = NotificationStatusBarStyle_Default; +#define PREF_KEY_NOTIF_SORT_MODE "notifSortMode" +static NotificationSortMode s_notification_sort_mode = NotificationSortNewestFirst; + +#define PREF_KEY_NOTIF_GROUPING "notifGrouping" +static bool s_notification_grouping = true; + /////////////////////////////////// //! Legacy preference keys /////////////////////////////////// @@ -345,6 +351,8 @@ void alerts_preferences_init(void) { RESTORE_PREF(PREF_KEY_NOTIF_VIBE_DELAY, s_notification_vibe_delay); RESTORE_PREF(PREF_KEY_NOTIF_BACKLIGHT, s_notification_backlight); RESTORE_PREF(PREF_KEY_NOTIF_STATUS_BAR_STYLE, s_notification_status_bar_style); + RESTORE_PREF(PREF_KEY_NOTIF_SORT_MODE, s_notification_sort_mode); + RESTORE_PREF(PREF_KEY_NOTIF_GROUPING, s_notification_grouping); RESTORE_PREF(PREF_KEY_DND_AUTO_DISMISS, s_dnd_auto_dismiss); #undef RESTORE_PREF @@ -448,6 +456,27 @@ void alerts_preferences_set_notification_status_bar_style(NotificationStatusBarS SET_PREF(PREF_KEY_NOTIF_STATUS_BAR_STYLE, s_notification_status_bar_style); } +NotificationSortMode alerts_preferences_get_notification_sort_mode(void) { + return s_notification_sort_mode; +} + +void alerts_preferences_set_notification_sort_mode(NotificationSortMode mode) { + if (mode >= NotificationSortModeCount) { + mode = NotificationSortNewestFirst; + } + s_notification_sort_mode = mode; + SET_PREF(PREF_KEY_NOTIF_SORT_MODE, s_notification_sort_mode); +} + +bool alerts_preferences_get_notification_grouping(void) { + return s_notification_grouping; +} + +void alerts_preferences_set_notification_grouping(bool enable) { + s_notification_grouping = enable; + SET_PREF(PREF_KEY_NOTIF_GROUPING, s_notification_grouping); +} + bool alerts_preferences_get_speaker_muted(void) { return s_speaker_muted; } @@ -755,4 +784,4 @@ void alerts_preferences_handle_blob_db_event(PebbleBlobDBEvent *event) { }; event_put(&pref_event); } -} \ No newline at end of file +}