From 3b03de3e1bac378bbbdf122110fea94f45981b9d Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 12 Apr 2026 22:20:10 +0300 Subject: i2: split FastForge core and history modules --- src/c/fastforge.c | 1012 +------------------------------------------- src/c/fastforge_core.c | 496 ++++++++++++++++++++++ src/c/fastforge_history.c | 502 ++++++++++++++++++++++ src/c/fastforge_internal.h | 43 ++ 4 files changed, 1059 insertions(+), 994 deletions(-) create mode 100644 src/c/fastforge_core.c create mode 100644 src/c/fastforge_history.c create mode 100644 src/c/fastforge_internal.h (limited to 'src/c') diff --git a/src/c/fastforge.c b/src/c/fastforge.c index 61bc42c..18c94fe 100644 --- a/src/c/fastforge.c +++ b/src/c/fastforge.c @@ -1,4 +1,4 @@ -#include "fastforge.h" +#include "fastforge_internal.h" #include #include @@ -29,21 +29,6 @@ enum { PRESET_MENU_ITEM_COUNT = 8 }; -typedef enum { - EDIT_FIELD_START = 0, - EDIT_FIELD_END = 1, - EDIT_FIELD_NOTE = 2 -} EditField; - -FastEntry history[MAX_FASTS]; -int history_count = 0; -FastEntry current_fast = {0}; -uint16_t global_target_minutes = DEFAULT_TARGET_MINUTES; -bool developer_mode_enabled = false; -StreakData streak_data = {0}; -AppTimer *alarm_timer = NULL; -time_t target_time = 0; - static Window *s_menu_window; static Window *s_timer_window; static Window *s_goal_window; @@ -58,7 +43,7 @@ static Window *s_running_edit_window; static SimpleMenuLayer *s_main_menu_layer; static SimpleMenuLayer *s_presets_menu_layer; -static MenuLayer *s_history_menu_layer; +MenuLayer *s_history_menu_layer; static SimpleMenuSection s_main_menu_sections[1]; static SimpleMenuSection s_presets_menu_sections[1]; static SimpleMenuItem s_main_menu_items[MAIN_MENU_ITEM_COUNT]; @@ -91,7 +76,7 @@ static TextLayer *s_placeholder_title_layer; static TextLayer *s_placeholder_body_layer; static TextLayer *s_placeholder_hint_layer; static TextLayer *s_stats_title_layer; -static TextLayer *s_stats_body_layer; +TextLayer *s_stats_body_layer; static TextLayer *s_stats_hint_layer; static TextLayer *s_history_edit_title_layer; static TextLayer *s_history_edit_start_layer; @@ -122,7 +107,6 @@ static char s_menu_stop_subtitle[32]; static char s_placeholder_title_text[24]; static char s_placeholder_body_text[160]; static char s_placeholder_hint_text[24]; -static char s_stats_body_text[160]; static char s_history_edit_title_text[48]; static char s_history_edit_start_text[32]; static char s_history_edit_end_text[32]; @@ -134,38 +118,7 @@ static char s_running_edit_elapsed_text[32]; static char s_running_edit_goal_text[32]; static int s_history_edit_index = -1; static FastEntry s_history_edit_draft = {0}; -static EditField s_history_edit_field = EDIT_FIELD_START; -static bool s_history_edit_dirty = false; -static time_t s_last_streak_refresh_day = 0; - -/* Snapshot history before AppMessage export so rows stay stable while sending. */ -#ifndef PBL_PLATFORM_APLITE -typedef struct { - bool active; - int next_row; - int snapshot_count; - FastEntry snapshot[MAX_FASTS]; -} HistoryExportState; - -static HistoryExportState s_history_export = {0}; -#endif -static const char *const s_note_tags[] = { - "", - "felt amazing", - "workout day", - "travel day", - "social day", - "busy day", - "reset day", - "late meal", - "high energy", - "rough day" -}; - #ifdef DEBUG -static bool s_fake_time_enabled = false; -static int32_t s_fake_time_offset_seconds = 0; -static int32_t s_current_fast_origin_offset_seconds = 0; static Window *s_debug_window; static SimpleMenuLayer *s_debug_menu_layer; static SimpleMenuSection s_debug_menu_sections[1]; @@ -173,30 +126,25 @@ static SimpleMenuItem s_debug_menu_items[6]; static char s_debug_menu_clock_text[40]; #endif -static const uint32_t s_alarm_vibe[] = {200, 100, 200, 100, 400, 100, 200}; -static VibePattern s_alarm_pattern = { - .durations = s_alarm_vibe, - .num_segments = ARRAY_LENGTH(s_alarm_vibe), -}; +typedef enum { + EDIT_FIELD_START = 0, + EDIT_FIELD_END = 1, + EDIT_FIELD_NOTE = 2 +} EditField; static void refresh_timer_view(void); static void refresh_goal_window_content(void); static void sync_main_menu_state(void); -static void refresh_stats_window_content(void); -static void history_menu_reload(void); static void refresh_running_edit_window_content(void); static void refresh_science_window_content(void); static void refresh_settings_window_content(void); -static void refresh_all_ui_state(void); -static void recompute_streak_data_from_history(void); -static time_t entry_duration_seconds(const FastEntry *entry); -static void recompute_streak_data_for_today(void); -static bool refresh_streak_if_day_changed(void); -static void request_history_export(void); #ifdef DEBUG static void show_debug_menu_window(void); #endif +static EditField s_history_edit_field = EDIT_FIELD_START; +static bool s_history_edit_dirty = false; + static bool safe_push_window(Window *window, bool animated) { if (!window) { return false; @@ -208,893 +156,6 @@ static bool safe_push_window(Window *window, bool animated) { return true; } -static int clamp_history_count(int value) { - if (value < 0) { - return 0; - } - if (value > MAX_FASTS) { - return MAX_FASTS; - } - return value; -} - -static void normalize_loaded_data(void) { - if (global_target_minutes == 0) { - global_target_minutes = DEFAULT_TARGET_MINUTES; - } - - if (current_fast.start_time != 0 && current_fast.target_minutes == 0) { - current_fast.target_minutes = global_target_minutes; - } - - if (current_fast.end_time != 0 && current_fast.end_time < current_fast.start_time) { - current_fast.end_time = 0; - } -} - -static time_t local_day_start(time_t timestamp) { - if (timestamp <= 0) { - return 0; - } - - struct tm tm_copy; - struct tm *tm_info = localtime(×tamp); - if (!tm_info) { - return 0; - } - - tm_copy = *tm_info; - tm_copy.tm_hour = 0; - tm_copy.tm_min = 0; - tm_copy.tm_sec = 0; - tm_copy.tm_isdst = -1; - return mktime(&tm_copy); -} - -static int compare_time_t_ascending(const void *a, const void *b) { - const time_t time_a = *(const time_t *)a; - const time_t time_b = *(const time_t *)b; - if (time_a < time_b) { - return -1; - } - if (time_a > time_b) { - return 1; - } - return 0; -} - -static bool is_next_local_day(time_t first_day, time_t second_day) { - if (first_day <= 0 || second_day <= 0) { - return false; - } - - struct tm next_day_tm; - struct tm *tm_info = localtime(&first_day); - if (!tm_info) { - return false; - } - - next_day_tm = *tm_info; - next_day_tm.tm_mday += 1; - next_day_tm.tm_hour = 0; - next_day_tm.tm_min = 0; - next_day_tm.tm_sec = 0; - next_day_tm.tm_isdst = -1; - time_t expected_next_day = mktime(&next_day_tm); - return expected_next_day == second_day; -} - -#ifdef DEBUG -static time_t fastforge_now(void) { - return time(NULL) + (s_fake_time_enabled ? s_fake_time_offset_seconds : 0); -} -#else -static time_t fastforge_now(void) { - return time(NULL); -} -#endif - -static int collect_completion_days(time_t *completion_days, int max_days) { - int completion_day_count = 0; - - for (int i = 0; i < history_count && completion_day_count < max_days; i++) { - const FastEntry *entry = &history[i]; - time_t duration = entry_duration_seconds(entry); - if (duration <= 0 || entry->end_time <= 0) { - continue; - } - - if (entry->end_time > streak_data.last_completed_fast_end) { - streak_data.last_completed_fast_end = entry->end_time; - } - - time_t day_start = local_day_start(entry->end_time); - if (day_start <= 0) { - continue; - } - completion_days[completion_day_count++] = day_start; - } - - return completion_day_count; -} - -/* Streaks come from unique local completion days, sorted before counting runs. */ -static void apply_completion_days_to_streaks(time_t *completion_days, int completion_day_count) { - qsort(completion_days, (size_t)completion_day_count, sizeof(time_t), compare_time_t_ascending); - - uint16_t run_length = 0; - uint16_t longest = 0; - for (int i = 0; i < completion_day_count; i++) { - if (i == 0 || completion_days[i] == completion_days[i - 1]) { - if (i == 0) { - run_length = 1; - } - continue; - } - - if (is_next_local_day(completion_days[i - 1], completion_days[i])) { - run_length++; - } else { - run_length = 1; - } - - if (run_length > longest) { - longest = run_length; - } - } - - if (longest == 0) { - longest = 1; - } - - time_t today_day_start = local_day_start(fastforge_now()); - time_t last_completion_day = completion_days[completion_day_count - 1]; - if (last_completion_day == today_day_start || - is_next_local_day(last_completion_day, today_day_start)) { - streak_data.current_streak = run_length; - } else { - streak_data.current_streak = 0; - } - streak_data.longest_streak = longest; -} - -static void recompute_streak_data_from_history(void) { - streak_data.current_streak = 0; - streak_data.longest_streak = 0; - streak_data.last_completed_fast_end = 0; - - if (history_count <= 0) { - return; - } - - time_t completion_days[MAX_FASTS]; - int completion_day_count = collect_completion_days(completion_days, MAX_FASTS); - if (completion_day_count == 0) { - return; - } - - apply_completion_days_to_streaks(completion_days, completion_day_count); -} - -static void recompute_streak_data_for_today(void) { - recompute_streak_data_from_history(); - s_last_streak_refresh_day = local_day_start(fastforge_now()); -} - -static bool refresh_streak_if_day_changed(void) { - time_t today_day_start = local_day_start(fastforge_now()); - if (today_day_start <= 0 || today_day_start == s_last_streak_refresh_day) { - return false; - } - - recompute_streak_data_for_today(); - save_all_data(); - return true; -} - -void save_all_data(void) { - persist_write_int(KEY_HISTORY_COUNT, history_count); - persist_write_data(KEY_HISTORY_DATA, history, sizeof(FastEntry) * history_count); - persist_write_data(KEY_CURRENT_FAST, ¤t_fast, sizeof(FastEntry)); - persist_write_int(KEY_TARGET_MIN, global_target_minutes); - persist_write_data(KEY_STREAK_DATA, &streak_data, sizeof(StreakData)); - persist_write_bool(KEY_DEV_MODE, developer_mode_enabled); -#ifdef DEBUG - persist_write_int(KEY_DEBUG_FAKE_OFFSET, s_fake_time_offset_seconds); - persist_write_int(KEY_DEBUG_FAST_ORIGIN, s_current_fast_origin_offset_seconds); -#endif -} - -static void reset_loaded_data(void) { - history_count = 0; - memset(history, 0, sizeof(history)); - memset(¤t_fast, 0, sizeof(current_fast)); - global_target_minutes = DEFAULT_TARGET_MINUTES; - developer_mode_enabled = false; - memset(&streak_data, 0, sizeof(streak_data)); -#ifdef DEBUG - s_fake_time_enabled = false; - s_fake_time_offset_seconds = 0; - s_current_fast_origin_offset_seconds = 0; -#endif -} - -static void load_persisted_base_data(void) { - if (persist_exists(KEY_HISTORY_COUNT)) { - history_count = clamp_history_count(persist_read_int(KEY_HISTORY_COUNT)); - } - if (history_count > 0 && persist_exists(KEY_HISTORY_DATA)) { - const int expected_size = sizeof(FastEntry) * history_count; - const int read_size = persist_read_data(KEY_HISTORY_DATA, history, expected_size); - if (read_size != expected_size) { - history_count = 0; - memset(history, 0, sizeof(history)); - } - } - if (persist_exists(KEY_CURRENT_FAST)) { - const int read_size = persist_read_data(KEY_CURRENT_FAST, ¤t_fast, sizeof(FastEntry)); - if (read_size != (int)sizeof(FastEntry)) { - memset(¤t_fast, 0, sizeof(current_fast)); - } - } - if (persist_exists(KEY_TARGET_MIN)) { - const int read_target = persist_read_int(KEY_TARGET_MIN); - if (read_target > 0 && read_target <= UINT16_MAX) { - global_target_minutes = (uint16_t)read_target; - } - } - if (persist_exists(KEY_STREAK_DATA)) { - const int read_size = persist_read_data(KEY_STREAK_DATA, &streak_data, sizeof(StreakData)); - if (read_size != (int)sizeof(StreakData)) { - memset(&streak_data, 0, sizeof(streak_data)); - } - } - if (persist_exists(KEY_DEV_MODE)) { - developer_mode_enabled = persist_read_bool(KEY_DEV_MODE); - } -} - -static void load_persisted_debug_data(void) { -#ifdef DEBUG - if (persist_exists(KEY_DEBUG_FAKE_OFFSET)) { - s_fake_time_offset_seconds = persist_read_int(KEY_DEBUG_FAKE_OFFSET); - s_fake_time_enabled = s_fake_time_offset_seconds != 0; - } - if (persist_exists(KEY_DEBUG_FAST_ORIGIN)) { - s_current_fast_origin_offset_seconds = persist_read_int(KEY_DEBUG_FAST_ORIGIN); - } -#endif -} - -static void finish_loaded_data(void) { - normalize_loaded_data(); - recompute_streak_data_for_today(); -} - -void load_all_data(void) { - reset_loaded_data(); - load_persisted_base_data(); - load_persisted_debug_data(); - finish_loaded_data(); -} - -bool fast_is_running(void) { - return current_fast.start_time != 0; -} - -static void format_hhmmss(time_t seconds, char *buffer, size_t size) { - if (seconds < 0) { - seconds = 0; - } - snprintf(buffer, size, "%02d:%02d:%02d", - (int)(seconds / 3600), - (int)((seconds % 3600) / 60), - (int)(seconds % 60)); -} - -static void format_duration_hours_minutes(time_t seconds, char *buffer, size_t size) { - if (seconds < 0) { - seconds = 0; - } - int hours = (int)(seconds / 3600); - int minutes = (int)((seconds % 3600) / 60); - snprintf(buffer, size, "%dh %02dm", hours, minutes); -} - -static time_t entry_duration_seconds(const FastEntry *entry) { - if (!entry || entry->start_time == 0 || entry->end_time <= entry->start_time) { - return 0; - } - return entry->end_time - entry->start_time; -} - -static void format_entry_datetime(time_t timestamp, char *buffer, size_t size) { - if (!buffer || size == 0) { - return; - } - if (timestamp <= 0) { - snprintf(buffer, size, "--"); - return; - } - - struct tm *tm_info = localtime(×tamp); - if (!tm_info) { - snprintf(buffer, size, "--"); - return; - } - strftime(buffer, size, "%b %d %H:%M", tm_info); -} - -#ifndef PBL_PLATFORM_APLITE -static size_t csv_append_text(char *buffer, size_t size, size_t offset, const char *value) { - if (!buffer || size == 0 || offset >= size) { - return offset; - } - - bool needs_quotes = false; - if (value) { - for (const char *cursor = value; *cursor; cursor++) { - if (*cursor == ',' || *cursor == '"' || *cursor == '\n' || *cursor == '\r') { - needs_quotes = true; - break; - } - } - } - - if (needs_quotes) { - if (offset + 1 < size) { - buffer[offset++] = '"'; - } - if (value) { - for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { - if (*cursor == '"') { - if (offset + 2 >= size) { - break; - } - buffer[offset++] = '"'; - buffer[offset++] = '"'; - } else { - buffer[offset++] = *cursor; - } - } - } - if (offset + 1 < size) { - buffer[offset++] = '"'; - } - } else { - if (value) { - for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { - buffer[offset++] = *cursor; - } - } - } - - if (offset < size) { - buffer[offset] = '\0'; - } - return offset; -} - -static size_t csv_append_int(char *buffer, size_t size, size_t offset, long value) { - char number_text[16]; - snprintf(number_text, sizeof(number_text), "%ld", value); - return csv_append_text(buffer, size, offset, number_text); -} - -static void format_history_csv_row(const FastEntry *entry, char *buffer, size_t size) { - if (!buffer || size == 0) { - return; - } - - buffer[0] = '\0'; - if (!entry) { - return; - } - - size_t offset = 0; - offset = csv_append_int(buffer, size, offset, (long)entry->start_time); - if (offset + 1 < size) { - buffer[offset++] = ','; - buffer[offset] = '\0'; - } - offset = csv_append_int(buffer, size, offset, (long)entry->end_time); - if (offset + 1 < size) { - buffer[offset++] = ','; - buffer[offset] = '\0'; - } - offset = csv_append_int(buffer, size, offset, (long)entry->target_minutes); - if (offset + 1 < size) { - buffer[offset++] = ','; - buffer[offset] = '\0'; - } - offset = csv_append_text(buffer, size, offset, entry->note); - if (offset + 1 < size) { - buffer[offset++] = ','; - buffer[offset] = '\0'; - } - (void)csv_append_int(buffer, size, offset, (long)entry->max_stage_reached); -} - -static void format_history_csv_header(char *buffer, size_t size) { - if (!buffer || size == 0) { - return; - } - snprintf(buffer, size, "start_time,end_time,target_minutes,note,max_stage_reached"); -} - -static void stop_history_export(void) { - s_history_export.active = false; - s_history_export.next_row = 0; - s_history_export.snapshot_count = 0; - memset(s_history_export.snapshot, 0, sizeof(s_history_export.snapshot)); -} -#endif - -static const char *milestone_badge_label_for_level(uint8_t stage_level) { - if (stage_level >= 3) { - return "Deep Ketosis"; - } - if (stage_level == 2) { - return "Ketosis"; - } - if (stage_level == 1) { - return "Fat Burn"; - } - return NULL; -} - -static void format_optional_tag_text(const char *prefix, const char *value, char *buffer, size_t size) { - if (!buffer || size == 0) { - return; - } - - if (!value || value[0] == '\0') { - snprintf(buffer, size, "%s--", prefix ? prefix : ""); - return; - } - - snprintf(buffer, size, "%s%s", prefix ? prefix : "", value); -} - -static const char *history_entry_badge_label(const FastEntry *entry) { - if (!entry) { - return NULL; - } - return milestone_badge_label_for_level(entry->max_stage_reached); -} - -static int note_tag_index_for_entry(const FastEntry *entry) { - if (!entry || entry->note[0] == '\0') { - return 0; - } - - for (int i = 1; i < (int)ARRAY_LENGTH(s_note_tags); i++) { - if (strncmp(entry->note, s_note_tags[i], sizeof(entry->note)) == 0) { - return i; - } - } - - return 0; -} - -static void set_entry_note_from_tag_index(FastEntry *entry, int tag_index) { - if (!entry) { - return; - } - - if (tag_index <= 0 || tag_index >= (int)ARRAY_LENGTH(s_note_tags)) { - memset(entry->note, 0, sizeof(entry->note)); - return; - } - - snprintf(entry->note, sizeof(entry->note), "%s", s_note_tags[tag_index]); -} - -static int history_index_for_row(int row) { - if (row < 0 || row >= history_count) { - return -1; - } - return history_count - 1 - row; -} - -static int compare_history_entries_by_end_time(const void *a, const void *b) { - const FastEntry *entry_a = a; - const FastEntry *entry_b = b; - if (entry_a->end_time < entry_b->end_time) { - return -1; - } - if (entry_a->end_time > entry_b->end_time) { - return 1; - } - if (entry_a->start_time < entry_b->start_time) { - return -1; - } - if (entry_a->start_time > entry_b->start_time) { - return 1; - } - return 0; -} - -static void sort_history_by_end_time(void) { - if (history_count <= 1) { - return; - } - qsort(history, (size_t)history_count, sizeof(FastEntry), compare_history_entries_by_end_time); -} - -static void format_history_row(int row, char *title, size_t title_size, char *subtitle, size_t subtitle_size) { - if (!title || !subtitle || title_size == 0 || subtitle_size == 0) { - return; - } - if (history_count == 0) { - snprintf(title, title_size, "No completed fasts"); - snprintf(subtitle, subtitle_size, "Start + stop a fast first"); - return; - } - - int history_index = history_index_for_row(row); - if (history_index < 0) { - snprintf(title, title_size, "Unavailable"); - subtitle[0] = '\0'; - return; - } - - const FastEntry *entry = &history[history_index]; - char date_text[24]; - char duration_text[20]; - char badge_text[24]; - char note_text[32]; - const char *badge_label = history_entry_badge_label(entry); - format_entry_datetime(entry->end_time, date_text, sizeof(date_text)); - format_duration_hours_minutes(entry_duration_seconds(entry), duration_text, sizeof(duration_text)); - snprintf(title, title_size, "%s", date_text); - note_text[0] = '\0'; - if (entry->note[0] != '\0') { - snprintf(note_text, sizeof(note_text), " | %s", entry->note); - } - badge_text[0] = '\0'; - if (badge_label) { - snprintf(badge_text, sizeof(badge_text), " | %s", badge_label); - } - snprintf(subtitle, subtitle_size, "%s%s%s", duration_text, badge_text, note_text); -} - -static void collect_stats_summary(time_t *total_seconds, time_t *longest_seconds, - int *completed_count, int *successful_count) { - time_t total_seconds_value = 0; - time_t longest_seconds_value = 0; - int completed_count_value = 0; - int successful_count_value = 0; - - for (int i = 0; i < history_count; i++) { - const FastEntry *entry = &history[i]; - time_t duration = entry_duration_seconds(entry); - if (duration <= 0) { - continue; - } - - completed_count_value++; - total_seconds_value += duration; - if (duration > longest_seconds_value) { - longest_seconds_value = duration; - } - if (entry->target_minutes > 0 && duration >= (time_t)entry->target_minutes * 60) { - successful_count_value++; - } - } - - if (total_seconds) { - *total_seconds = total_seconds_value; - } - if (longest_seconds) { - *longest_seconds = longest_seconds_value; - } - if (completed_count) { - *completed_count = completed_count_value; - } - if (successful_count) { - *successful_count = successful_count_value; - } -} - -static void format_stats_window_body(time_t total_seconds, time_t longest_seconds, - int completed_count, int successful_count) { - if (completed_count == 0) { - snprintf(s_stats_body_text, sizeof(s_stats_body_text), - "No completed fasts yet.\n" - "Start and stop your first\n" - "fast to populate stats.\n" - "Streak: %u current / %u best", - streak_data.current_streak, - streak_data.longest_streak); - } else { - char avg_text[20]; - char total_text[20]; - char longest_text[20]; - time_t average_seconds = total_seconds / completed_count; - int success_rate = (successful_count * 100 + completed_count / 2) / completed_count; - - format_duration_hours_minutes(average_seconds, avg_text, sizeof(avg_text)); - format_duration_hours_minutes(total_seconds, total_text, sizeof(total_text)); - format_duration_hours_minutes(longest_seconds, longest_text, sizeof(longest_text)); - snprintf(s_stats_body_text, sizeof(s_stats_body_text), - "Avg fast: %s\n" - "Total fasted: %s\n" - "Success: %d%% (%d/%d)\n" - "Longest: %s\n" - "Streak: %u / %u", - avg_text, - total_text, - success_rate, - successful_count, - completed_count, - longest_text, - streak_data.current_streak, - streak_data.longest_streak); - } -} - -static void refresh_stats_window_content(void) { - if (!s_stats_body_layer) { - return; - } - - time_t total_seconds = 0; - time_t longest_seconds = 0; - int completed_count = 0; - int successful_count = 0; - collect_stats_summary(&total_seconds, &longest_seconds, &completed_count, &successful_count); - format_stats_window_body(total_seconds, longest_seconds, completed_count, successful_count); - text_layer_set_text(s_stats_body_layer, s_stats_body_text); -} - -static uint8_t stage_level_for_elapsed(time_t elapsed_seconds) { - if (elapsed_seconds >= 24 * 3600) { - return 3; - } - if (elapsed_seconds >= 18 * 3600) { - return 2; - } - if (elapsed_seconds >= 12 * 3600) { - return 1; - } - return 0; -} - -static const char *stage_text_for_elapsed(time_t elapsed_seconds) { - if (elapsed_seconds >= 24 * 3600) { - return "DEEP KETOSIS"; - } - if (elapsed_seconds >= 18 * 3600) { - return "EARLY KETOSIS"; - } - if (elapsed_seconds >= 12 * 3600) { - return "FAT BURN"; - } - return "GLYCOGEN"; -} - -#ifndef PBL_PLATFORM_APLITE -static void history_export_send_next(void); - -static void history_export_on_sent(DictionaryIterator *iterator, void *context) { - (void)iterator; - (void)context; - if (!s_history_export.active) { - return; - } - - s_history_export.next_row++; - if (s_history_export.next_row >= s_history_export.snapshot_count + 1) { - APP_LOG(APP_LOG_LEVEL_INFO, "History export finished with %d rows", s_history_export.snapshot_count + 1); - stop_history_export(); - return; - } - - history_export_send_next(); -} - -static void history_export_on_failed(DictionaryIterator *iterator, AppMessageResult reason, void *context) { - (void)iterator; - (void)context; - APP_LOG(APP_LOG_LEVEL_ERROR, "History export failed at row %d (%d)", s_history_export.next_row, reason); - stop_history_export(); -} - -static const char *history_export_status_for_row(void) { - if (s_history_export.next_row == 0) { - return s_history_export.snapshot_count == 0 ? "EXPORT_COMPLETE" : "EXPORT_STARTED"; - } - if (s_history_export.next_row >= s_history_export.snapshot_count) { - return "EXPORT_COMPLETE"; - } - return "EXPORT_ROW"; -} - -static void history_export_send_message(const char *row_text) { - DictionaryIterator *outbox = NULL; - AppMessageResult result = app_message_outbox_begin(&outbox); - if (result != APP_MSG_OK || !outbox) { - APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to start history export outbox (%d)", result); - stop_history_export(); - return; - } - - dict_write_int32(outbox, MESSAGE_KEY_EXPORT_SEQUENCE, s_history_export.next_row); - dict_write_int32(outbox, MESSAGE_KEY_EXPORT_TOTAL, s_history_export.snapshot_count + 1); - dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_ROW, row_text ? row_text : ""); - dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_STATUS, history_export_status_for_row()); - result = app_message_outbox_send(); - if (result != APP_MSG_OK) { - APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to queue history export message (%d)", result); - stop_history_export(); - } -} - -static void history_export_send_next(void) { - if (!s_history_export.active) { - return; - } - - char row_text[128]; - if (s_history_export.next_row == 0) { - format_history_csv_header(row_text, sizeof(row_text)); - } else { - int history_index = s_history_export.next_row - 1; - if (history_index < 0 || history_index >= s_history_export.snapshot_count) { - stop_history_export(); - return; - } - format_history_csv_row(&s_history_export.snapshot[history_index], row_text, sizeof(row_text)); - } - - history_export_send_message(row_text); -} - -static bool history_export_begin(void) { - if (s_history_export.active) { - return false; - } - - s_history_export.active = true; - s_history_export.next_row = 0; - s_history_export.snapshot_count = history_count; - if (s_history_export.snapshot_count > 0) { - memcpy(s_history_export.snapshot, history, sizeof(FastEntry) * (size_t)s_history_export.snapshot_count); - } - history_export_send_next(); - return true; -} - -static void history_export_inbox_received(DictionaryIterator *iterator, void *context) { - (void)context; - Tuple *command_tuple = dict_find(iterator, MESSAGE_KEY_EXPORT_COMMAND); - if (!command_tuple || command_tuple->type != TUPLE_CSTRING) { - return; - } - - if (strcmp(command_tuple->value->cstring, "EXPORT_HISTORY") == 0) { - APP_LOG(APP_LOG_LEVEL_INFO, "Received export request from companion"); - history_export_begin(); - } -} -#endif - -static void update_max_stage_if_needed(time_t elapsed_seconds) { - if (!fast_is_running()) { - return; - } - - uint8_t stage_level = stage_level_for_elapsed(elapsed_seconds); - if (stage_level > current_fast.max_stage_reached) { - current_fast.max_stage_reached = stage_level; - save_all_data(); - } -} - -static bool running_fast_is_at_target(time_t now) { - if (!fast_is_running() || current_fast.target_minutes == 0) { - return false; - } - return now >= current_fast.start_time + (time_t)current_fast.target_minutes * 60; -} - -static void append_history_entry(const FastEntry *entry) { - if (!entry) { - return; - } - - if (history_count < MAX_FASTS) { - history[history_count++] = *entry; - return; - } - - memmove(&history[0], &history[1], sizeof(FastEntry) * (MAX_FASTS - 1)); - history[MAX_FASTS - 1] = *entry; -} - -static uint16_t resolve_target_minutes(uint16_t preset_target_minutes) { - if (preset_target_minutes > 0) { - return preset_target_minutes; - } - if (global_target_minutes == 0) { - global_target_minutes = DEFAULT_TARGET_MINUTES; - } - return global_target_minutes; -} - -static void alarm_callback(void *data); - -static void schedule_alarm_if_needed(void) { - if (alarm_timer) { - app_timer_cancel(alarm_timer); - alarm_timer = NULL; - } - - if (!fast_is_running() || current_fast.target_minutes == 0) { - target_time = 0; - return; - } - - target_time = current_fast.start_time + (time_t)current_fast.target_minutes * 60; - time_t now = fastforge_now(); - if (target_time <= now) { - alarm_callback(NULL); - return; - } - - uint32_t ms_until_alarm = (uint32_t)(target_time - now) * 1000; - alarm_timer = app_timer_register(ms_until_alarm, alarm_callback, NULL); -} - -bool fast_start(uint16_t preset_target_minutes) { - if (fast_is_running()) { - return false; - } - - current_fast.start_time = fastforge_now(); - current_fast.end_time = 0; - current_fast.target_minutes = resolve_target_minutes(preset_target_minutes); - memset(current_fast.note, 0, sizeof(current_fast.note)); - current_fast.max_stage_reached = 0; -#ifdef DEBUG - s_current_fast_origin_offset_seconds = s_fake_time_enabled ? s_fake_time_offset_seconds : 0; -#endif - save_all_data(); - schedule_alarm_if_needed(); - APP_LOG(APP_LOG_LEVEL_INFO, "Started fast at %ld target=%u", - (long)current_fast.start_time, current_fast.target_minutes); - return true; -} - -bool fast_stop(void) { - if (!fast_is_running()) { - return false; - } - - FastEntry completed = current_fast; - completed.end_time = fastforge_now(); - if (completed.end_time < completed.start_time) { - completed.end_time = completed.start_time; - } - append_history_entry(&completed); - sort_history_by_end_time(); - recompute_streak_data_for_today(); - memset(¤t_fast, 0, sizeof(current_fast)); -#ifdef DEBUG - s_current_fast_origin_offset_seconds = 0; -#endif - if (alarm_timer) { - app_timer_cancel(alarm_timer); - alarm_timer = NULL; - } - target_time = 0; - save_all_data(); - history_menu_reload(); - APP_LOG(APP_LOG_LEVEL_INFO, "Stopped fast and saved history_count=%d", history_count); - return true; -} - static void set_placeholder_content(const char *title, const char *body, const char *hint) { snprintf(s_placeholder_title_text, sizeof(s_placeholder_title_text), "%s", title ? title : ""); snprintf(s_placeholder_body_text, sizeof(s_placeholder_body_text), "%s", body ? body : ""); @@ -1110,7 +171,7 @@ static void set_placeholder_content(const char *title, const char *body, const c } } -static void show_placeholder_window(const char *title, const char *body, const char *hint) { +void show_placeholder_window(const char *title, const char *body, const char *hint) { set_placeholder_content(title, body, hint); if (!window_stack_contains_window(s_detail_window)) { window_stack_push(s_detail_window, true); @@ -1312,7 +373,7 @@ static void debug_force_goal_alarm(void) { app_timer_cancel(alarm_timer); alarm_timer = NULL; } - alarm_callback(NULL); + fastforge_force_goal_alarm(); debug_refresh_menu(); } @@ -1552,27 +613,17 @@ static void sync_main_menu_state(void) { } } -static void refresh_all_ui_state(void) { +void refresh_all_ui_state(void) { refresh_timer_view(); refresh_goal_window_content(); sync_main_menu_state(); } -static void show_goal_reached_window(void) { +void show_goal_reached_window(void) { refresh_goal_window_content(); safe_push_window(s_goal_window, true); } -static void alarm_callback(void *data) { - (void)data; - alarm_timer = NULL; - target_time = 0; - vibes_enqueue_custom_pattern(s_alarm_pattern); - light_enable_interaction(); - show_goal_reached_window(); - refresh_all_ui_state(); -} - static void start_fast_from_preset(uint16_t target_minutes) { if (fast_is_running()) { show_placeholder_window("FAST RUNNING", @@ -1729,12 +780,6 @@ static void history_menu_select_callback(MenuLayer *menu_layer, MenuIndex *cell_ history_open_edit_for_row(cell_index->row); } -static void history_menu_reload(void) { - if (s_history_menu_layer) { - menu_layer_reload_data(s_history_menu_layer); - } -} - static void history_adjust_edit_draft_by_minutes(int delta_minutes) { if (s_history_edit_index < 0 || s_history_edit_index >= history_count) { return; @@ -1761,7 +806,7 @@ static void history_adjust_edit_note_by_delta(int delta) { } int tag_index = note_tag_index_for_entry(&s_history_edit_draft); - int tag_count = (int)ARRAY_LENGTH(s_note_tags); + int tag_count = history_note_tag_count(); tag_index = (tag_index + delta) % tag_count; if (tag_index < 0) { tag_index += tag_count; @@ -2014,25 +1059,6 @@ static void menu_backup_callback(int index, void *context) { request_history_export(); } -static void request_history_export(void) { -#ifndef PBL_PLATFORM_APLITE - if (!history_export_begin()) { - show_placeholder_window("BACKUP BUSY", - "A history export is already in progress.", - "BACK Menu"); - return; - } - - show_placeholder_window("BACKUP TO PHONE", - "CSV export started.\nCompanion stub stores the file.", - "BACK Menu"); -#else - show_placeholder_window("BACKUP UNAVAILABLE", - "AppMessage backup is disabled on Aplite.", - "BACK Menu"); -#endif -} - static void preset_16h_callback(int index, void *context) { (void)index; (void)context; @@ -2911,9 +1937,7 @@ static void init(void) { if (app_message_result != APP_MSG_OK) { APP_LOG(APP_LOG_LEVEL_ERROR, "app_message_open failed (%d)", app_message_result); } else { - app_message_register_inbox_received(history_export_inbox_received); - app_message_register_outbox_sent(history_export_on_sent); - app_message_register_outbox_failed(history_export_on_failed); + fastforge_history_register_app_message_handlers(); } #endif tick_timer_service_subscribe(SECOND_UNIT, tick_handler); @@ -2932,7 +1956,7 @@ static void deinit(void) { alarm_timer = NULL; } #ifndef PBL_PLATFORM_APLITE - stop_history_export(); + fastforge_history_stop_export(); #endif target_time = 0; save_all_data(); diff --git a/src/c/fastforge_core.c b/src/c/fastforge_core.c new file mode 100644 index 0000000..3d9eeb8 --- /dev/null +++ b/src/c/fastforge_core.c @@ -0,0 +1,496 @@ +#include "fastforge_internal.h" + +#include +#include + +FastEntry history[MAX_FASTS]; +int history_count = 0; +FastEntry current_fast = {0}; +uint16_t global_target_minutes = DEFAULT_TARGET_MINUTES; +bool developer_mode_enabled = false; +StreakData streak_data = {0}; +AppTimer *alarm_timer = NULL; +time_t target_time = 0; + +#ifdef DEBUG +bool s_fake_time_enabled = false; +int32_t s_fake_time_offset_seconds = 0; +int32_t s_current_fast_origin_offset_seconds = 0; +#endif + +static time_t s_last_streak_refresh_day = 0; +static const uint32_t s_alarm_vibe[] = {200, 100, 200, 100, 400, 100, 200}; +static VibePattern s_alarm_pattern = { + .durations = s_alarm_vibe, + .num_segments = ARRAY_LENGTH(s_alarm_vibe), +}; + +static int clamp_history_count(int value) { + if (value < 0) { + return 0; + } + if (value > MAX_FASTS) { + return MAX_FASTS; + } + return value; +} + +static void normalize_loaded_data(void) { + if (global_target_minutes == 0) { + global_target_minutes = DEFAULT_TARGET_MINUTES; + } + + if (current_fast.start_time != 0 && current_fast.target_minutes == 0) { + current_fast.target_minutes = global_target_minutes; + } + + if (current_fast.end_time != 0 && current_fast.end_time < current_fast.start_time) { + current_fast.end_time = 0; + } +} + +time_t local_day_start(time_t timestamp) { + if (timestamp <= 0) { + return 0; + } + + struct tm tm_copy; + struct tm *tm_info = localtime(×tamp); + if (!tm_info) { + return 0; + } + + tm_copy = *tm_info; + tm_copy.tm_hour = 0; + tm_copy.tm_min = 0; + tm_copy.tm_sec = 0; + tm_copy.tm_isdst = -1; + return mktime(&tm_copy); +} + +static int compare_time_t_ascending(const void *a, const void *b) { + const time_t time_a = *(const time_t *)a; + const time_t time_b = *(const time_t *)b; + if (time_a < time_b) { + return -1; + } + if (time_a > time_b) { + return 1; + } + return 0; +} + +static bool is_next_local_day(time_t first_day, time_t second_day) { + if (first_day <= 0 || second_day <= 0 || second_day < first_day) { + return false; + } + + struct tm *tm_info = localtime(&first_day); + if (!tm_info) { + return false; + } + + struct tm next_day_tm = *tm_info; + next_day_tm.tm_mday += 1; + next_day_tm.tm_hour = 0; + next_day_tm.tm_min = 0; + next_day_tm.tm_sec = 0; + next_day_tm.tm_isdst = -1; + time_t expected_next_day = mktime(&next_day_tm); + return expected_next_day == second_day; +} + +#ifdef DEBUG +time_t fastforge_now(void) { + return time(NULL) + (s_fake_time_enabled ? s_fake_time_offset_seconds : 0); +} +#else +time_t fastforge_now(void) { + return time(NULL); +} +#endif + +static int collect_completion_days(time_t *completion_days, int max_days) { + int completion_day_count = 0; + + for (int i = 0; i < history_count && completion_day_count < max_days; i++) { + const FastEntry *entry = &history[i]; + time_t duration = entry_duration_seconds(entry); + if (duration <= 0 || entry->end_time <= 0) { + continue; + } + + if (entry->end_time > streak_data.last_completed_fast_end) { + streak_data.last_completed_fast_end = entry->end_time; + } + + time_t day_start = local_day_start(entry->end_time); + if (day_start <= 0) { + continue; + } + completion_days[completion_day_count++] = day_start; + } + + return completion_day_count; +} + +static void apply_completion_days_to_streaks(time_t *completion_days, int completion_day_count) { + qsort(completion_days, (size_t)completion_day_count, sizeof(time_t), compare_time_t_ascending); + + uint16_t run_length = 0; + uint16_t longest = 0; + for (int i = 0; i < completion_day_count; i++) { + if (i == 0 || completion_days[i] == completion_days[i - 1]) { + if (i == 0) { + run_length = 1; + } + continue; + } + + if (is_next_local_day(completion_days[i - 1], completion_days[i])) { + run_length++; + } else { + run_length = 1; + } + + if (run_length > longest) { + longest = run_length; + } + } + + if (longest == 0) { + longest = 1; + } + + time_t today_day_start = local_day_start(fastforge_now()); + time_t last_completion_day = completion_days[completion_day_count - 1]; + if (last_completion_day == today_day_start || + is_next_local_day(last_completion_day, today_day_start)) { + streak_data.current_streak = run_length; + } else { + streak_data.current_streak = 0; + } + streak_data.longest_streak = longest; +} + +static void recompute_streak_data_from_history(void) { + streak_data.current_streak = 0; + streak_data.longest_streak = 0; + streak_data.last_completed_fast_end = 0; + + if (history_count <= 0) { + return; + } + + time_t completion_days[MAX_FASTS]; + int completion_day_count = collect_completion_days(completion_days, MAX_FASTS); + if (completion_day_count == 0) { + return; + } + + apply_completion_days_to_streaks(completion_days, completion_day_count); +} + +void recompute_streak_data_for_today(void) { + recompute_streak_data_from_history(); + s_last_streak_refresh_day = local_day_start(fastforge_now()); +} + +bool refresh_streak_if_day_changed(void) { + time_t today_day_start = local_day_start(fastforge_now()); + if (today_day_start <= 0 || today_day_start == s_last_streak_refresh_day) { + return false; + } + + recompute_streak_data_for_today(); + save_all_data(); + return true; +} + +void save_all_data(void) { + persist_write_int(KEY_HISTORY_COUNT, history_count); + persist_write_data(KEY_HISTORY_DATA, history, sizeof(FastEntry) * history_count); + persist_write_data(KEY_CURRENT_FAST, ¤t_fast, sizeof(FastEntry)); + persist_write_int(KEY_TARGET_MIN, global_target_minutes); + persist_write_data(KEY_STREAK_DATA, &streak_data, sizeof(StreakData)); + persist_write_bool(KEY_DEV_MODE, developer_mode_enabled); +#ifdef DEBUG + persist_write_int(KEY_DEBUG_FAKE_OFFSET, s_fake_time_offset_seconds); + persist_write_int(KEY_DEBUG_FAST_ORIGIN, s_current_fast_origin_offset_seconds); +#endif +} + +static void reset_loaded_data(void) { + history_count = 0; + memset(history, 0, sizeof(history)); + memset(¤t_fast, 0, sizeof(current_fast)); + global_target_minutes = DEFAULT_TARGET_MINUTES; + developer_mode_enabled = false; + memset(&streak_data, 0, sizeof(streak_data)); +#ifdef DEBUG + s_fake_time_enabled = false; + s_fake_time_offset_seconds = 0; + s_current_fast_origin_offset_seconds = 0; +#endif +} + +static void load_persisted_base_data(void) { + if (persist_exists(KEY_HISTORY_COUNT)) { + history_count = clamp_history_count(persist_read_int(KEY_HISTORY_COUNT)); + } + if (history_count > 0 && persist_exists(KEY_HISTORY_DATA)) { + const int expected_size = sizeof(FastEntry) * history_count; + const int read_size = persist_read_data(KEY_HISTORY_DATA, history, expected_size); + if (read_size != expected_size) { + history_count = 0; + memset(history, 0, sizeof(history)); + } + } + if (persist_exists(KEY_CURRENT_FAST)) { + const int read_size = persist_read_data(KEY_CURRENT_FAST, ¤t_fast, sizeof(FastEntry)); + if (read_size != (int)sizeof(FastEntry)) { + memset(¤t_fast, 0, sizeof(current_fast)); + } + } + if (persist_exists(KEY_TARGET_MIN)) { + const int read_target = persist_read_int(KEY_TARGET_MIN); + if (read_target > 0 && read_target <= UINT16_MAX) { + global_target_minutes = (uint16_t)read_target; + } + } + if (persist_exists(KEY_STREAK_DATA)) { + const int read_size = persist_read_data(KEY_STREAK_DATA, &streak_data, sizeof(StreakData)); + if (read_size != (int)sizeof(StreakData)) { + memset(&streak_data, 0, sizeof(streak_data)); + } + } + if (persist_exists(KEY_DEV_MODE)) { + developer_mode_enabled = persist_read_bool(KEY_DEV_MODE); + } +} + +static void load_persisted_debug_data(void) { +#ifdef DEBUG + if (persist_exists(KEY_DEBUG_FAKE_OFFSET)) { + s_fake_time_offset_seconds = persist_read_int(KEY_DEBUG_FAKE_OFFSET); + s_fake_time_enabled = s_fake_time_offset_seconds != 0; + } + if (persist_exists(KEY_DEBUG_FAST_ORIGIN)) { + s_current_fast_origin_offset_seconds = persist_read_int(KEY_DEBUG_FAST_ORIGIN); + } +#endif +} + +static void finish_loaded_data(void) { + normalize_loaded_data(); + recompute_streak_data_for_today(); +} + +void load_all_data(void) { + reset_loaded_data(); + load_persisted_base_data(); + load_persisted_debug_data(); + finish_loaded_data(); +} + +bool fast_is_running(void) { + return current_fast.start_time != 0; +} + +void format_hhmmss(time_t seconds, char *buffer, size_t size) { + if (seconds < 0) { + seconds = 0; + } + snprintf(buffer, size, "%02d:%02d:%02d", + (int)(seconds / 3600), + (int)((seconds % 3600) / 60), + (int)(seconds % 60)); +} + +void format_duration_hours_minutes(time_t seconds, char *buffer, size_t size) { + if (seconds < 0) { + seconds = 0; + } + int hours = (int)(seconds / 3600); + int minutes = (int)((seconds % 3600) / 60); + snprintf(buffer, size, "%dh %02dm", hours, minutes); +} + +time_t entry_duration_seconds(const FastEntry *entry) { + if (!entry || entry->start_time == 0 || entry->end_time <= entry->start_time) { + return 0; + } + return entry->end_time - entry->start_time; +} + +void format_entry_datetime(time_t timestamp, char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + if (timestamp <= 0) { + snprintf(buffer, size, "--"); + return; + } + + struct tm *tm_info = localtime(×tamp); + if (!tm_info) { + snprintf(buffer, size, "--"); + return; + } + strftime(buffer, size, "%b %d %H:%M", tm_info); +} + +uint8_t stage_level_for_elapsed(time_t elapsed_seconds) { + if (elapsed_seconds >= 24 * 3600) { + return 3; + } + if (elapsed_seconds >= 18 * 3600) { + return 2; + } + if (elapsed_seconds >= 12 * 3600) { + return 1; + } + return 0; +} + +const char *stage_text_for_elapsed(time_t elapsed_seconds) { + if (elapsed_seconds >= 24 * 3600) { + return "DEEP KETOSIS"; + } + if (elapsed_seconds >= 18 * 3600) { + return "EARLY KETOSIS"; + } + if (elapsed_seconds >= 12 * 3600) { + return "FAT BURN"; + } + return "GLYCOGEN"; +} + +void update_max_stage_if_needed(time_t elapsed_seconds) { + if (!fast_is_running()) { + return; + } + + uint8_t stage_level = stage_level_for_elapsed(elapsed_seconds); + if (stage_level > current_fast.max_stage_reached) { + current_fast.max_stage_reached = stage_level; + save_all_data(); + } +} + +bool running_fast_is_at_target(time_t now) { + if (!fast_is_running() || current_fast.target_minutes == 0) { + return false; + } + return now >= current_fast.start_time + (time_t)current_fast.target_minutes * 60; +} + +static void append_history_entry(const FastEntry *entry) { + if (!entry) { + return; + } + + if (history_count < MAX_FASTS) { + history[history_count++] = *entry; + return; + } + + memmove(&history[0], &history[1], sizeof(FastEntry) * (MAX_FASTS - 1)); + history[MAX_FASTS - 1] = *entry; +} + +static uint16_t resolve_target_minutes(uint16_t preset_target_minutes) { + if (preset_target_minutes > 0) { + return preset_target_minutes; + } + if (global_target_minutes == 0) { + global_target_minutes = DEFAULT_TARGET_MINUTES; + } + return global_target_minutes; +} + +static void alarm_callback(void *data); + +void schedule_alarm_if_needed(void) { + if (alarm_timer) { + app_timer_cancel(alarm_timer); + alarm_timer = NULL; + } + + if (!fast_is_running() || current_fast.target_minutes == 0) { + target_time = 0; + return; + } + + target_time = current_fast.start_time + (time_t)current_fast.target_minutes * 60; + time_t now = fastforge_now(); + if (target_time <= now) { + alarm_callback(NULL); + return; + } + + uint32_t ms_until_alarm = (uint32_t)(target_time - now) * 1000; + alarm_timer = app_timer_register(ms_until_alarm, alarm_callback, NULL); +} + +bool fast_start(uint16_t preset_target_minutes) { + if (fast_is_running()) { + return false; + } + + current_fast.start_time = fastforge_now(); + current_fast.end_time = 0; + current_fast.target_minutes = resolve_target_minutes(preset_target_minutes); + memset(current_fast.note, 0, sizeof(current_fast.note)); + current_fast.max_stage_reached = 0; +#ifdef DEBUG + s_current_fast_origin_offset_seconds = s_fake_time_enabled ? s_fake_time_offset_seconds : 0; +#endif + save_all_data(); + schedule_alarm_if_needed(); + APP_LOG(APP_LOG_LEVEL_INFO, "Started fast at %ld target=%u", + (long)current_fast.start_time, current_fast.target_minutes); + return true; +} + +bool fast_stop(void) { + if (!fast_is_running()) { + return false; + } + + FastEntry completed = current_fast; + completed.end_time = fastforge_now(); + if (completed.end_time < completed.start_time) { + completed.end_time = completed.start_time; + } + append_history_entry(&completed); + sort_history_by_end_time(); + recompute_streak_data_for_today(); + memset(¤t_fast, 0, sizeof(current_fast)); +#ifdef DEBUG + s_current_fast_origin_offset_seconds = 0; +#endif + if (alarm_timer) { + app_timer_cancel(alarm_timer); + alarm_timer = NULL; + } + target_time = 0; + save_all_data(); + history_menu_reload(); + APP_LOG(APP_LOG_LEVEL_INFO, "Stopped fast and saved history_count=%d", history_count); + return true; +} + +static void alarm_callback(void *data) { + (void)data; + alarm_timer = NULL; + target_time = 0; + vibes_enqueue_custom_pattern(s_alarm_pattern); + light_enable_interaction(); + show_goal_reached_window(); + refresh_all_ui_state(); +} + +void fastforge_force_goal_alarm(void) { + alarm_callback(NULL); +} diff --git a/src/c/fastforge_history.c b/src/c/fastforge_history.c new file mode 100644 index 0000000..8631162 --- /dev/null +++ b/src/c/fastforge_history.c @@ -0,0 +1,502 @@ +#include "fastforge_internal.h" + +#include +#include + +#include "message_keys.auto.h" + +static const char *const s_note_tags[] = { + "", + "felt amazing", + "workout day", + "travel day", + "social day", + "busy day", + "reset day", + "late meal", + "high energy", + "rough day" +}; + +#ifndef PBL_PLATFORM_APLITE +typedef struct { + bool active; + int next_row; + int snapshot_count; + FastEntry snapshot[MAX_FASTS]; +} HistoryExportState; + +static HistoryExportState s_history_export = {0}; +#endif + +static char s_stats_body_text[160]; + +static int compare_history_entries_by_end_time(const void *a, const void *b) { + const FastEntry *entry_a = a; + const FastEntry *entry_b = b; + if (entry_a->end_time < entry_b->end_time) { + return -1; + } + if (entry_a->end_time > entry_b->end_time) { + return 1; + } + if (entry_a->start_time < entry_b->start_time) { + return -1; + } + if (entry_a->start_time > entry_b->start_time) { + return 1; + } + return 0; +} + +void sort_history_by_end_time(void) { + if (history_count <= 1) { + return; + } + qsort(history, (size_t)history_count, sizeof(FastEntry), compare_history_entries_by_end_time); +} + +const char *milestone_badge_label_for_level(uint8_t stage_level) { + if (stage_level >= 3) { + return "Deep Ketosis"; + } + if (stage_level == 2) { + return "Ketosis"; + } + if (stage_level == 1) { + return "Fat Burn"; + } + return NULL; +} + +void format_optional_tag_text(const char *prefix, const char *value, char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + + if (!value || value[0] == '\0') { + snprintf(buffer, size, "%s--", prefix ? prefix : ""); + return; + } + + snprintf(buffer, size, "%s%s", prefix ? prefix : "", value); +} + +const char *history_entry_badge_label(const FastEntry *entry) { + if (!entry) { + return NULL; + } + return milestone_badge_label_for_level(entry->max_stage_reached); +} + +int note_tag_index_for_entry(const FastEntry *entry) { + if (!entry || entry->note[0] == '\0') { + return 0; + } + + for (int i = 1; i < (int)ARRAY_LENGTH(s_note_tags); i++) { + if (strncmp(entry->note, s_note_tags[i], sizeof(entry->note)) == 0) { + return i; + } + } + + return 0; +} + +int history_note_tag_count(void) { + return (int)ARRAY_LENGTH(s_note_tags); +} + +void set_entry_note_from_tag_index(FastEntry *entry, int tag_index) { + if (!entry) { + return; + } + + if (tag_index <= 0 || tag_index >= (int)ARRAY_LENGTH(s_note_tags)) { + memset(entry->note, 0, sizeof(entry->note)); + return; + } + + snprintf(entry->note, sizeof(entry->note), "%s", s_note_tags[tag_index]); +} + +int history_index_for_row(int row) { + if (row < 0 || row >= history_count) { + return -1; + } + return history_count - 1 - row; +} + +static void format_history_row_impl(int row, char *title, size_t title_size, char *subtitle, size_t subtitle_size) { + if (!title || !subtitle || title_size == 0 || subtitle_size == 0) { + return; + } + if (history_count == 0) { + snprintf(title, title_size, "No completed fasts"); + snprintf(subtitle, subtitle_size, "Start + stop a fast first"); + return; + } + + int history_index = history_index_for_row(row); + if (history_index < 0) { + snprintf(title, title_size, "Unavailable"); + subtitle[0] = '\0'; + return; + } + + const FastEntry *entry = &history[history_index]; + char date_text[24]; + char duration_text[20]; + char badge_text[24]; + char note_text[32]; + const char *badge_label = history_entry_badge_label(entry); + format_entry_datetime(entry->end_time, date_text, sizeof(date_text)); + format_duration_hours_minutes(entry_duration_seconds(entry), duration_text, sizeof(duration_text)); + snprintf(title, title_size, "%s", date_text); + note_text[0] = '\0'; + if (entry->note[0] != '\0') { + snprintf(note_text, sizeof(note_text), " | %s", entry->note); + } + badge_text[0] = '\0'; + if (badge_label) { + snprintf(badge_text, sizeof(badge_text), " | %s", badge_label); + } + snprintf(subtitle, subtitle_size, "%s%s%s", duration_text, badge_text, note_text); +} + +void format_history_row(int row, char *title, size_t title_size, char *subtitle, size_t subtitle_size) { + format_history_row_impl(row, title, title_size, subtitle, subtitle_size); +} + +static void collect_stats_summary(time_t *total_seconds, time_t *longest_seconds, + int *completed_count, int *successful_count) { + time_t total_seconds_value = 0; + time_t longest_seconds_value = 0; + int completed_count_value = 0; + int successful_count_value = 0; + + for (int i = 0; i < history_count; i++) { + const FastEntry *entry = &history[i]; + time_t duration = entry_duration_seconds(entry); + if (duration <= 0) { + continue; + } + + completed_count_value++; + total_seconds_value += duration; + if (duration > longest_seconds_value) { + longest_seconds_value = duration; + } + if (entry->target_minutes > 0 && duration >= (time_t)entry->target_minutes * 60) { + successful_count_value++; + } + } + + if (total_seconds) { + *total_seconds = total_seconds_value; + } + if (longest_seconds) { + *longest_seconds = longest_seconds_value; + } + if (completed_count) { + *completed_count = completed_count_value; + } + if (successful_count) { + *successful_count = successful_count_value; + } +} + +static void format_stats_window_body(time_t total_seconds, time_t longest_seconds, + int completed_count, int successful_count) { + if (completed_count == 0) { + snprintf(s_stats_body_text, sizeof(s_stats_body_text), + "No completed fasts yet.\n" + "Start and stop your first\n" + "fast to populate stats.\n" + "Streak: %u current / %u best", + streak_data.current_streak, + streak_data.longest_streak); + } else { + char avg_text[20]; + char total_text[20]; + char longest_text[20]; + time_t average_seconds = total_seconds / completed_count; + int success_rate = (successful_count * 100 + completed_count / 2) / completed_count; + + format_duration_hours_minutes(average_seconds, avg_text, sizeof(avg_text)); + format_duration_hours_minutes(total_seconds, total_text, sizeof(total_text)); + format_duration_hours_minutes(longest_seconds, longest_text, sizeof(longest_text)); + snprintf(s_stats_body_text, sizeof(s_stats_body_text), + "Avg fast: %s\n" + "Total fasted: %s\n" + "Success: %d%% (%d/%d)\n" + "Longest: %s\n" + "Streak: %u / %u", + avg_text, + total_text, + success_rate, + successful_count, + completed_count, + longest_text, + streak_data.current_streak, + streak_data.longest_streak); + } +} + +void refresh_stats_window_content(void) { + if (!s_stats_body_layer) { + return; + } + + time_t total_seconds = 0; + time_t longest_seconds = 0; + int completed_count = 0; + int successful_count = 0; + collect_stats_summary(&total_seconds, &longest_seconds, &completed_count, &successful_count); + format_stats_window_body(total_seconds, longest_seconds, completed_count, successful_count); + text_layer_set_text(s_stats_body_layer, s_stats_body_text); +} + +void history_menu_reload(void) { + if (s_history_menu_layer) { + menu_layer_reload_data(s_history_menu_layer); + } +} + +#ifndef PBL_PLATFORM_APLITE +static void stop_history_export(void) { + s_history_export.active = false; + s_history_export.next_row = 0; + s_history_export.snapshot_count = 0; + memset(s_history_export.snapshot, 0, sizeof(s_history_export.snapshot)); +} + +static size_t csv_append_text(char *buffer, size_t size, size_t offset, const char *value) { + if (!buffer || size == 0 || offset >= size) { + return offset; + } + + bool needs_quotes = false; + if (value) { + for (const char *cursor = value; *cursor; cursor++) { + if (*cursor == ',' || *cursor == '"' || *cursor == '\n' || *cursor == '\r') { + needs_quotes = true; + break; + } + } + } + + if (needs_quotes) { + if (offset + 1 < size) { + buffer[offset++] = '"'; + } + if (value) { + for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { + if (*cursor == '"') { + if (offset + 2 >= size) { + break; + } + buffer[offset++] = '"'; + buffer[offset++] = '"'; + } else { + buffer[offset++] = *cursor; + } + } + } + if (offset + 1 < size) { + buffer[offset++] = '"'; + } + } else { + if (value) { + for (const char *cursor = value; *cursor && offset + 1 < size; cursor++) { + buffer[offset++] = *cursor; + } + } + } + + if (offset < size) { + buffer[offset] = '\0'; + } + return offset; +} + +static size_t csv_append_int(char *buffer, size_t size, size_t offset, long value) { + char number_text[16]; + snprintf(number_text, sizeof(number_text), "%ld", value); + return csv_append_text(buffer, size, offset, number_text); +} + +static void format_history_csv_row(const FastEntry *entry, char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + + buffer[0] = '\0'; + if (!entry) { + return; + } + + size_t offset = 0; + offset = csv_append_int(buffer, size, offset, (long)entry->start_time); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_int(buffer, size, offset, (long)entry->end_time); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_int(buffer, size, offset, (long)entry->target_minutes); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + offset = csv_append_text(buffer, size, offset, entry->note); + if (offset + 1 < size) { + buffer[offset++] = ','; + buffer[offset] = '\0'; + } + (void)csv_append_int(buffer, size, offset, (long)entry->max_stage_reached); +} + +static void format_history_csv_header(char *buffer, size_t size) { + if (!buffer || size == 0) { + return; + } + snprintf(buffer, size, "start_time,end_time,target_minutes,note,max_stage_reached"); +} + +static const char *history_export_status_for_row(void) { + if (s_history_export.next_row == 0) { + return s_history_export.snapshot_count == 0 ? "EXPORT_COMPLETE" : "EXPORT_STARTED"; + } + if (s_history_export.next_row >= s_history_export.snapshot_count) { + return "EXPORT_COMPLETE"; + } + return "EXPORT_ROW"; +} + +static void history_export_send_message(const char *row_text) { + DictionaryIterator *outbox = NULL; + AppMessageResult result = app_message_outbox_begin(&outbox); + if (result != APP_MSG_OK || !outbox) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to start history export outbox (%d)", result); + stop_history_export(); + return; + } + + dict_write_int32(outbox, MESSAGE_KEY_EXPORT_SEQUENCE, s_history_export.next_row); + dict_write_int32(outbox, MESSAGE_KEY_EXPORT_TOTAL, s_history_export.snapshot_count + 1); + dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_ROW, row_text ? row_text : ""); + dict_write_cstring(outbox, MESSAGE_KEY_EXPORT_STATUS, history_export_status_for_row()); + result = app_message_outbox_send(); + if (result != APP_MSG_OK) { + APP_LOG(APP_LOG_LEVEL_ERROR, "Unable to queue history export message (%d)", result); + stop_history_export(); + } +} + +static void history_export_send_next(void) { + if (!s_history_export.active) { + return; + } + + char row_text[128]; + if (s_history_export.next_row == 0) { + format_history_csv_header(row_text, sizeof(row_text)); + } else { + int history_index = s_history_export.next_row - 1; + if (history_index < 0 || history_index >= s_history_export.snapshot_count) { + stop_history_export(); + return; + } + format_history_csv_row(&s_history_export.snapshot[history_index], row_text, sizeof(row_text)); + } + + history_export_send_message(row_text); +} + +static void history_export_on_sent(DictionaryIterator *iterator, void *context) { + (void)iterator; + (void)context; + if (!s_history_export.active) { + return; + } + + s_history_export.next_row++; + if (s_history_export.next_row >= s_history_export.snapshot_count + 1) { + APP_LOG(APP_LOG_LEVEL_INFO, "History export finished with %d rows", s_history_export.snapshot_count + 1); + stop_history_export(); + return; + } + + history_export_send_next(); +} + +static void history_export_on_failed(DictionaryIterator *iterator, AppMessageResult reason, void *context) { + (void)iterator; + (void)context; + APP_LOG(APP_LOG_LEVEL_ERROR, "History export failed at row %d (%d)", s_history_export.next_row, reason); + stop_history_export(); +} + +static bool history_export_begin(void) { + if (s_history_export.active) { + return false; + } + + s_history_export.active = true; + s_history_export.next_row = 0; + s_history_export.snapshot_count = history_count; + if (s_history_export.snapshot_count > 0) { + memcpy(s_history_export.snapshot, history, sizeof(FastEntry) * (size_t)s_history_export.snapshot_count); + } + history_export_send_next(); + return true; +} + +static void history_export_inbox_received(DictionaryIterator *iterator, void *context) { + (void)context; + Tuple *command_tuple = dict_find(iterator, MESSAGE_KEY_EXPORT_COMMAND); + if (!command_tuple || command_tuple->type != TUPLE_CSTRING) { + return; + } + + if (strcmp(command_tuple->value->cstring, "EXPORT_HISTORY") == 0) { + APP_LOG(APP_LOG_LEVEL_INFO, "Received export request from companion"); + history_export_begin(); + } +} +#endif + +void request_history_export(void) { +#ifndef PBL_PLATFORM_APLITE + if (!history_export_begin()) { + show_placeholder_window("BACKUP BUSY", + "A history export is already in progress.", + "BACK Menu"); + return; + } + + show_placeholder_window("BACKUP TO PHONE", + "CSV export started.\nCompanion stub stores the file.", + "BACK Menu"); +#else + show_placeholder_window("BACKUP UNAVAILABLE", + "AppMessage backup is disabled on Aplite.", + "BACK Menu"); +#endif +} + +#ifndef PBL_PLATFORM_APLITE +void fastforge_history_register_app_message_handlers(void) { + app_message_register_inbox_received(history_export_inbox_received); + app_message_register_outbox_sent(history_export_on_sent); + app_message_register_outbox_failed(history_export_on_failed); +} + +void fastforge_history_stop_export(void) { + stop_history_export(); +} +#endif diff --git a/src/c/fastforge_internal.h b/src/c/fastforge_internal.h new file mode 100644 index 0000000..c0a2463 --- /dev/null +++ b/src/c/fastforge_internal.h @@ -0,0 +1,43 @@ +#ifndef FASTFORGE_INTERNAL_H +#define FASTFORGE_INTERNAL_H + +#include "fastforge.h" + +extern bool s_fake_time_enabled; +extern int32_t s_fake_time_offset_seconds; +extern int32_t s_current_fast_origin_offset_seconds; +extern MenuLayer *s_history_menu_layer; +extern TextLayer *s_stats_body_layer; + +time_t fastforge_now(void); +void recompute_streak_data_for_today(void); +bool refresh_streak_if_day_changed(void); +void update_max_stage_if_needed(time_t elapsed_seconds); +void schedule_alarm_if_needed(void); +time_t entry_duration_seconds(const FastEntry *entry); +void format_hhmmss(time_t seconds, char *buffer, size_t size); +void format_duration_hours_minutes(time_t seconds, char *buffer, size_t size); +void format_entry_datetim