Import of the watch repository from Pebble

This commit is contained in:
Matthieu Jeanson 2024-12-12 16:43:03 -08:00 committed by Katharine Berry
commit 3b92768480
10334 changed files with 2564465 additions and 0 deletions

View file

@ -0,0 +1,805 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "applib/health_service.h"
#include "applib/app.h"
#include "applib/app_logging.h"
#include "applib/fonts/fonts.h"
#include "applib/persist.h"
#include "applib/ui/ui.h"
#include "applib/ui/dialogs/expandable_dialog.h"
#include "apps/system_app_ids.h"
#include "kernel/pbl_malloc.h"
#include "process_state/app_state/app_state.h"
#include "services/normal/activity/activity_algorithm.h"
#include "services/normal/activity/activity_insights.h"
#include "services/normal/data_logging/data_logging_service.h"
#include "shell/prefs.h"
#include "system/logging.h"
#include "util/size.h"
#include "util/string.h"
#include "util/trig.h"
#include "activity_demo_app.h"
#include <stdio.h>
#define CURRENT_STEP_AVG 500
#define DAILY_STEP_AVG 1000
// Persist keys
typedef enum {
AppPersistKeyLapSteps = 0,
} AppPersistKey;
// -------------------------------------------------------------------------------
// Structures
typedef struct {
char dialog_text[256];
SimpleMenuItem *menu_items;
SimpleMenuLayer *menu_layer;
} DebugCard;
// App globals
typedef struct {
Window *debug_window;
DebugCard debug_card;
uint32_t steps_offset;
uint32_t cur_steps;
} ActivityDemoAppData;
static ActivityDemoAppData *s_data;
// -------------------------------------------------------------------------------
static void prv_convert_seconds_to_time(uint32_t secs_after_midnight, char *text,
int text_len) {
uint32_t minutes_after_midnight = secs_after_midnight / SECONDS_PER_MINUTE;
uint32_t hour = minutes_after_midnight / MINUTES_PER_HOUR;
uint32_t minute = minutes_after_midnight % MINUTES_PER_HOUR;
snprintf(text, text_len, "%d:%02d", (int)hour, (int)minute);
}
// -----------------------------------------------------------------------------------------
static void prv_display_alert(const char *text) {
ExpandableDialog *expandable_dialog = expandable_dialog_create("Alert");
dialog_set_text(expandable_dialog_get_dialog(expandable_dialog), text);
expandable_dialog_show_action_bar(expandable_dialog, false);
app_expandable_dialog_push(expandable_dialog);
}
// -----------------------------------------------------------------------------------------
static void prv_display_scalar_history_alert(ActivityDemoAppData *data, const char *title,
ActivityMetric metric) {
strcpy(data->debug_card.dialog_text, title);
// Get History
int32_t values[7];
activity_get_metric(metric, ARRAY_LENGTH(values), values);
for (int i = 0; i < 7; i++) {
char temp[32];
snprintf(temp, sizeof(temp), "\n%d: %d", i, (int)values[i]);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
}
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_display_averages_alert(ActivityDemoAppData *data, DayInWeek day) {
ActivityMetricAverages *averages = app_malloc_check(sizeof(ActivityMetricAverages));
strcpy(data->debug_card.dialog_text, "Hourly avgs:");
activity_get_step_averages(day, averages);
// Sum into hours
const int k_avgs_per_hour = ACTIVITY_NUM_METRIC_AVERAGES / HOURS_PER_DAY;
for (int i = 0; i < HOURS_PER_DAY; i++) {
int value = 0;
for (int j = i * k_avgs_per_hour; j < (i + 1) * k_avgs_per_hour; j++) {
if (averages->average[j] == ACTIVITY_METRIC_AVERAGES_UNKNOWN) {
averages->average[j] = 0;
}
value += averages->average[j];
}
char temp[32];
snprintf(temp, sizeof(temp), "\n%02d: %d", i, value);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
}
prv_display_alert(data->debug_card.dialog_text);
app_free(averages);
}
// -----------------------------------------------------------------------------------------
static void prv_display_seconds_history_alert(ActivityDemoAppData *data, const char *title,
ActivityMetric metric) {
strcpy(data->debug_card.dialog_text, title);
// Get History
int32_t values[7];
activity_get_metric(metric, ARRAY_LENGTH(values), values);
for (int i = 0; i < 7; i++) {
char elapsed[8];
prv_convert_seconds_to_time(values[i], elapsed, sizeof(elapsed));
char temp[32];
snprintf(temp, sizeof(temp), "\n%d: %s", i, elapsed);
strcat(data->debug_card.dialog_text, temp);
}
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_set_steps(int32_t steps, ActivityDemoAppData *data) {
activity_test_set_steps_and_avg(steps, CURRENT_STEP_AVG, DAILY_STEP_AVG);
int32_t peek_steps = health_service_sum_today(HealthMetricStepCount);
data->cur_steps = peek_steps + data->steps_offset;
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Current steps changed to: %"PRIu32"\n", data->cur_steps);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_tracking(int index, void *context) {
ActivityDemoAppData *data = context;
bool enabled = activity_tracking_on();
enabled = !enabled;
if (enabled) {
activity_start_tracking(false /*test_mode*/);
} else {
activity_stop_tracking();
}
activity_prefs_tracking_set_enabled(enabled);
data->debug_card.menu_items[index].subtitle = enabled ? "Enabled" : "Disabled";
layer_mark_dirty(simple_menu_layer_get_layer(data->debug_card.menu_layer));
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_activity_insights(int index, void *context) {
ActivityDemoAppData *data = context;
bool enabled = activity_prefs_activity_insights_are_enabled();
enabled = !enabled;
activity_prefs_activity_insights_set_enabled(enabled);
data->debug_card.menu_items[index].subtitle = enabled ? "Enabled" : "Disabled";
layer_mark_dirty(simple_menu_layer_get_layer(data->debug_card.menu_layer));
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_sleep_insights(int index, void *context) {
ActivityDemoAppData *data = context;
bool enabled = activity_prefs_sleep_insights_are_enabled();
enabled = !enabled;
activity_prefs_sleep_insights_set_enabled(enabled);
data->debug_card.menu_items[index].subtitle = enabled ? "Enabled" : "Disabled";
layer_mark_dirty(simple_menu_layer_get_layer(data->debug_card.menu_layer));
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_dls_sends(int index, void *context) {
ActivityDemoAppData *data = context;
bool enabled = dls_get_send_enable();
enabled = !enabled;
dls_set_send_enable_pp(enabled);
data->debug_card.menu_items[index].subtitle = enabled ? "Enabled" : "Disabled";
layer_mark_dirty(simple_menu_layer_get_layer(data->debug_card.menu_layer));
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_set_steps_below_avg(int index, void *context) {
ActivityDemoAppData *data = context;
prv_set_steps(CURRENT_STEP_AVG - 250, data);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_set_steps_at_avg(int index, void *context) {
ActivityDemoAppData *data = context;
prv_set_steps(CURRENT_STEP_AVG, data);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_set_steps_above_avg(int index, void *context) {
ActivityDemoAppData *data = context;
prv_set_steps(CURRENT_STEP_AVG + 250, data);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_set_steps_history(int index, void *context) {
ActivityDemoAppData *data = context;
activity_test_set_steps_history();
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Step history changed");
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_set_sleep_history(int index, void *context) {
ActivityDemoAppData *data = context;
activity_test_set_sleep_history();
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Sleep history changed");
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_sleep_file_info(int index, void *context) {
ActivityDemoAppData *data = context;
// Get sleep file info
uint32_t num_records;
uint32_t data_bytes;
uint32_t minutes;
activity_test_minute_file_info(false /*compact_first*/, &num_records, &data_bytes, &minutes);
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Records: %d\nData bytes: %d\nMinutes: %d", (int)num_records, (int)data_bytes,
(int)minutes);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_sleep_file_compact(int index, void *context) {
ActivityDemoAppData *data = context;
// Get sleep file info
uint32_t num_records;
uint32_t data_bytes;
uint32_t minutes;
activity_test_minute_file_info(true /*compact_first*/, &num_records, &data_bytes, &minutes);
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"After compaction\nRecords: %d\nData bytes: %d\nMinutes: %d", (int)num_records,
(int)data_bytes, (int)minutes);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_resting_calorie_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_scalar_history_alert(data, "Resting Calories", ActivityMetricRestingKCalories);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_active_calorie_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_scalar_history_alert(data, "Active Calories", ActivityMetricActiveKCalories);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_step_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_scalar_history_alert(data, "Steps", ActivityMetricStepCount);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_sleep_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_seconds_history_alert(data, "Sleep total", ActivityMetricSleepTotalSeconds);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_active_time_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_seconds_history_alert(data, "Active Time", ActivityMetricActiveSeconds);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_distance_history(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_scalar_history_alert(data, "Distance(m)", ActivityMetricDistanceMeters);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_sleep_sessions(int index, void *context) {
ActivityDemoAppData *data = context;
// Allocate space for the sessions. Usually, there will only be about 4 or 5 sessions
// (1 container and a handful of restful periods). Allocating space for 32 (an arbitrary
// number) should be more than enough.
uint32_t num_sessions = 32;
ActivitySession *sessions = app_malloc(num_sessions * sizeof(ActivitySession));
if (!sessions) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Not enough memory");
return;
}
// Get sessions
bool success = activity_get_sessions(&num_sessions, sessions);
if (!success) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Error getting sleep sessions");
goto exit;
}
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Sleep sessions\n");
// Print info on each one
ActivitySession *session = sessions;
for (uint32_t i = 0; i < num_sessions; i++, session++) {
char *prefix = "";
bool deep_sleep = false;
switch (session->type) {
case ActivitySessionType_Sleep:
prefix = "s";
break;
case ActivitySessionType_Nap:
prefix = "n";
break;
case ActivitySessionType_RestfulSleep:
case ActivitySessionType_RestfulNap:
prefix = "*";
deep_sleep = true;
break;
default:
continue;
}
safe_strcat(data->debug_card.dialog_text, prefix, sizeof(data->debug_card.dialog_text));
// Write start time
struct tm local_tm;
char temp[32];
localtime_r(&session->start_utc, &local_tm);
strftime(temp, sizeof(temp), "%H:%M", &local_tm);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
// Write end time/elapsed
if (deep_sleep) {
snprintf(temp, sizeof(temp), " %dm\n", (int)(session->length_min));
} else {
time_t end_time = session->start_utc + (session->length_min * SECONDS_PER_MINUTE);
localtime_r(&end_time, &local_tm);
strftime(temp, sizeof(temp), "-%H:%M\n", &local_tm);
}
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
}
exit:
// Free session info memory
app_free(sessions);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_step_sessions(int index, void *context) {
ActivityDemoAppData *data = context;
// Allocate space for the sessions. Usually, there will only be about 4 or 5 sessions
// (1 container and a handful of restful periods). Allocating space for 32 (an arbitrary
// number) should be more than enough.
uint32_t num_sessions = 32;
ActivitySession *sessions = app_malloc(num_sessions * sizeof(ActivitySession));
if (!sessions) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Not enough memory");
return;
}
// Get sessions
bool success = activity_get_sessions(&num_sessions, sessions);
if (!success) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Error getting activity sessions");
goto exit;
}
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Step activities\n");
// Print info on each one
ActivitySession *session = sessions;
for (uint32_t i = 0; i < num_sessions; i++, session++) {
char *prefix = "";
switch (session->type) {
case ActivitySessionType_Sleep:
case ActivitySessionType_Nap:
case ActivitySessionType_RestfulSleep:
case ActivitySessionType_RestfulNap:
continue;
case ActivitySessionType_Walk:
prefix = "W";
break;
case ActivitySessionType_Run:
prefix = "R";
break;
default:
continue;
}
safe_strcat(data->debug_card.dialog_text, prefix, sizeof(data->debug_card.dialog_text));
// Write start time
struct tm local_tm;
char temp[64];
localtime_r(&session->start_utc, &local_tm);
strftime(temp, sizeof(temp), "%H:%M", &local_tm);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
// Write length
snprintf(temp, sizeof(temp), " %dm\n", (int)(session->length_min));
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
// Write steps, calories, distance
snprintf(temp, sizeof(temp), " %"PRIu16", %"PRIu16"C, %"PRIu16"m\n", session->step_data.steps,
session->step_data.active_kcalories + session->step_data.resting_kcalories,
session->step_data.distance_meters);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
}
exit:
// Free session info memory
app_free(sessions);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_weekday_averages(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_averages_alert(data, Monday);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_weekend_averages(int index, void *context) {
ActivityDemoAppData *data = context;
prv_display_averages_alert(data, Saturday);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_activity_prefs(int index, void *context) {
ActivityDemoAppData *data = context;
bool tracking_enabled = activity_prefs_tracking_is_enabled();
bool activity_insights_enabled = activity_prefs_activity_insights_are_enabled();
bool sleep_insights_enabled = activity_prefs_sleep_insights_are_enabled();
ActivityGender gender = activity_prefs_get_gender();
uint16_t height_mm = activity_prefs_get_height_mm();
uint16_t weight_dag = activity_prefs_get_weight_dag();
uint8_t age_years = activity_prefs_get_age_years();
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"activity tracking: %"PRIu8"\n"
"activity_insights: %"PRIu8"\n"
"sleep_insights: %"PRIu8"\n"
"gender: %"PRIu8"\n"
"height: %"PRIu16"\n"
"weight: %"PRIu16"\n"
"age: %"PRIu8"", tracking_enabled, activity_insights_enabled, sleep_insights_enabled,
gender, height_mm, weight_dag, age_years);
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_minute_data(int index, void *context) {
ActivityDemoAppData *data = context;
bool success;
const uint32_t k_size = 1000;
HealthMinuteData *minute_data = app_malloc(k_size * sizeof(HealthMinuteData));
if (!minute_data) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Out of memory");
prv_display_alert(data->debug_card.dialog_text);
goto exit;
}
// Start as far back as 30 days ago
time_t utc_start = rtc_get_time() - 30 * SECONDS_PER_DAY;
uint32_t num_records = 0;
while (true) {
uint32_t chunk = k_size;
time_t prior_start = utc_start;
success = activity_get_minute_history(minute_data, &chunk, &utc_start);
if (!success) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Failed");
prv_display_alert(data->debug_card.dialog_text);
goto exit;
}
PBL_LOG(LOG_LEVEL_DEBUG, "Got %d minutes with UTC of %d (delta of %d min)", (int)chunk,
(int)utc_start, (int)(utc_start - prior_start) / SECONDS_PER_MINUTE);
num_records += chunk;
utc_start += chunk * SECONDS_PER_MINUTE;
if (chunk == 0) {
break;
}
}
// Print summary
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Retrieved %d minute data records", (int)num_records);
// Print detail on the last few minutes
const int k_print_batch_size = k_size;
PBL_LOG(LOG_LEVEL_DEBUG, "Fetching last %d minutes", k_print_batch_size);
utc_start = rtc_get_time() - (k_print_batch_size * SECONDS_PER_MINUTE);
time_t prior_start = utc_start;
uint32_t chunk = k_print_batch_size;
success = activity_get_minute_history(minute_data, &chunk, &utc_start);
if (!success) {
snprintf(data->debug_card.dialog_text, sizeof(data->debug_card.dialog_text),
"Failed");
prv_display_alert(data->debug_card.dialog_text);
goto exit;
}
PBL_LOG(LOG_LEVEL_DEBUG, "Got last %d minutes with UTC of %d (delta of %d min)", (int)chunk,
(int)utc_start, (int)(utc_start - prior_start) / SECONDS_PER_MINUTE);
const unsigned int k_num_last_minutes = 6;
if (chunk >= k_num_last_minutes) {
for (int i = (int)chunk - k_num_last_minutes; i < (int)chunk; i++) {
HealthMinuteData *m_data = &minute_data[i];
char temp[32];
snprintf(temp, sizeof(temp), "\n%"PRId8", 0x%"PRIx8", %"PRIu16", %"PRId8" ",
m_data->steps, m_data->orientation, m_data->vmc, m_data->light);
safe_strcat(data->debug_card.dialog_text, temp, sizeof(data->debug_card.dialog_text));
}
}
prv_display_alert(data->debug_card.dialog_text);
exit:
app_free(minute_data);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_send_fake_logging_record(int index, void *context) {
activity_test_send_fake_dls_records();
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_push_summary_pins(int index, void *context) {
prv_debug_cmd_set_steps_at_avg(index, context);
activity_insights_test_push_summary_pins();
ActivityDemoAppData *data = context;
strncpy(data->debug_card.dialog_text, "Summary pins pushed",
sizeof(data->debug_card.dialog_text));
prv_display_alert(data->debug_card.dialog_text);
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_push_rewards(int index, void *context) {
activity_insights_test_push_rewards();
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_push_walk_run(int index, void *context) {
activity_insights_test_push_walk_run_sessions();
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_push_day_insights(int index, void *context) {
activity_insights_test_push_day_insights();
}
// -----------------------------------------------------------------------------------------
static void prv_debug_cmd_push_nap_session(int index, void *context) {
activity_insights_test_push_nap_session();
}
// -----------------------------------------------------------------------------------------
static void debug_window_load(Window *window) {
ActivityDemoAppData *data = window_get_user_data(window);
Layer *window_layer = window_get_root_layer(window);
const GRect *root_bounds = &window_layer->bounds;
static SimpleMenuItem menu_items[] = {
{
.title = "Tracking",
.callback = prv_debug_cmd_tracking,
}, {
.title = "Activity Insights",
.callback = prv_debug_cmd_activity_insights,
}, {
.title = "Sleep Insights",
.callback = prv_debug_cmd_sleep_insights,
}, {
.title = "DLS sends",
.callback = prv_debug_cmd_dls_sends,
}, {
.title = "Step History",
.callback = prv_debug_cmd_step_history,
}, {
.title = "Distance(m) History",
.callback = prv_debug_cmd_distance_history,
}, {
.title = "Resting Calorie History",
.callback = prv_debug_cmd_resting_calorie_history,
}, {
.title = "Active Calorie History",
.callback = prv_debug_cmd_active_calorie_history,
}, {
.title = "Active Minutes History",
.callback = prv_debug_cmd_active_time_history,
}, {
.title = "Sleep History",
.callback = prv_debug_cmd_sleep_history,
}, {
.title = "Sleep Sessions",
.callback = prv_debug_cmd_sleep_sessions,
}, {
.title = "Step activities",
.callback = prv_debug_cmd_step_sessions,
}, {
.title = "Weekday averages",
.callback = prv_debug_cmd_weekday_averages,
}, {
.title = "Weekend averages",
.callback = prv_debug_cmd_weekend_averages,
}, {
.title = "Activity Prefs",
.callback = prv_debug_cmd_activity_prefs,
}, {
.title = "Steps below avg",
.callback = prv_debug_cmd_set_steps_below_avg,
}, {
.title = "Steps at avg",
.callback = prv_debug_cmd_set_steps_at_avg,
}, {
.title = "Steps above avg",
.callback = prv_debug_cmd_set_steps_above_avg,
}, {
.title = "Set step history",
.callback = prv_debug_cmd_set_steps_history,
}, {
.title = "Set sleep history",
.callback = prv_debug_cmd_set_sleep_history,
}, {
.title = "Sleep File Info",
.callback = prv_debug_cmd_sleep_file_info,
}, {
.title = "Sleep File Compact",
.callback = prv_debug_cmd_sleep_file_compact,
}, {
.title = "Read Minute data",
.callback = prv_debug_cmd_minute_data,
}, {
.title = "Send fake DL record",
.callback = prv_debug_cmd_send_fake_logging_record,
}, {
.title = "Push Summary Pins",
.callback = prv_debug_cmd_push_summary_pins,
}, {
.title = "Push Rewards",
.callback = prv_debug_cmd_push_rewards,
}, {
.title = "Walk/Run Notif",
.callback = prv_debug_cmd_push_walk_run,
}, {
.title = "Push Day Insights",
.callback = prv_debug_cmd_push_day_insights,
}, {
.title = "Push Nap Session",
.callback = prv_debug_cmd_push_nap_session,
}
};
static const SimpleMenuSection sections[] = {
{
.items = menu_items,
.num_items = ARRAY_LENGTH(menu_items)
}
};
data->debug_card.menu_items = menu_items;
data->debug_card.menu_layer = simple_menu_layer_create(*root_bounds, window, sections,
ARRAY_LENGTH(sections), data);
layer_add_child(window_layer, simple_menu_layer_get_layer(data->debug_card.menu_layer));
// Init status
data->debug_card.menu_items[0].subtitle = activity_tracking_on() ? "Enabled" : "Disabled";
data->debug_card.menu_items[1].subtitle =
activity_prefs_activity_insights_are_enabled() ? "Enabled" : "Disabled";
data->debug_card.menu_items[2].subtitle =
activity_prefs_sleep_insights_are_enabled() ? "Enabled" : "Disabled";
data->debug_card.menu_items[3].subtitle = dls_get_send_enable() ? "Enabled" : "Disabled";
}
// -------------------------------------------------------------------------------
static void debug_window_unload(Window *window) {
ActivityDemoAppData *data = window_get_user_data(window);
simple_menu_layer_destroy(data->debug_card.menu_layer);
}
// -------------------------------------------------------------------------------
static void deinit(void) {
ActivityDemoAppData *data = app_state_get_user_data();
window_destroy(data->debug_window);
app_free(data);
}
// -------------------------------------------------------------------------------
static void init(void) {
ActivityDemoAppData *data = app_malloc_check(sizeof(ActivityDemoAppData));
s_data = data;
memset(data, 0, sizeof(ActivityDemoAppData));
app_state_set_user_data(data);
// Debug window
data->debug_window = window_create();
window_set_user_data(data->debug_window, data);
window_set_window_handlers(data->debug_window, &(WindowHandlers) {
.load = debug_window_load,
.unload = debug_window_unload,
});
app_window_stack_push(data->debug_window, true /* Animated */);
}
// -------------------------------------------------------------------------------
static void s_main(void) {
init();
app_event_loop();
deinit();
}
// -------------------------------------------------------------------------------
const PebbleProcessMd* activity_demo_get_app_info(void) {
static const PebbleProcessMdSystem s_activity_demo_app_info = {
.common.main_func = &s_main,
// UUID: 60206d97-818b-4f42-87ae-48fde623608d
.common.uuid = {0x60, 0x20, 0x6d, 0x97, 0x81, 0x8b, 0x4f, 0x42, 0x87, 0xae, 0x48, 0xfd, 0xe6,
0x23, 0x60, 0x8d},
.name = "ActivityDemo"
};
return (const PebbleProcessMd*) &s_activity_demo_app_info;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* activity_demo_get_app_info(void);

View file

@ -0,0 +1,251 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "alarm_detail.h"
#include "alarm_editor.h"
#include "applib/ui/action_menu_window.h"
#include "applib/ui/action_menu_window_private.h"
#include "applib/ui/dialogs/actionable_dialog.h"
#include "applib/ui/dialogs/simple_dialog.h"
#include "kernel/pbl_malloc.h"
#include "popups/health_tracking_ui.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/activity.h"
#include "services/normal/alarms/alarm.h"
#include "system/logging.h"
#include <stdio.h>
#define NUM_SNOOZE_MENU_ITEMS 5
typedef enum DetailMenuItemIndex {
DetailMenuItemIndexEnable = 0,
DetailMenuItemIndexDelete,
DetailMenuItemIndexChangeTime,
DetailMenuItemIndexChangeDays,
#if CAPABILITY_HAS_HEALTH_TRACKING
DetailMenuItemIndexConvertSmart,
#endif
DetailMenuItemIndexSnooze,
DetailMenuItemIndexNum,
} DetailMenuItemIndex;
typedef struct AlarmDetailData {
ActionMenuConfig menu_config;
AlarmId alarm_id;
AlarmInfo alarm_info;
AlarmEditorCompleteCallback alarm_editor_callback;
void *callback_context;
} AlarmDetailData;
static SimpleDialog *prv_snooze_set_confirm_dialog(void) {
SimpleDialog *simple_dialog = simple_dialog_create("AlarmSnoozeSet");
Dialog *dialog = simple_dialog_get_dialog(simple_dialog);
const char *snooze_text = i18n_noop("Snooze delay set to %d minutes");
char snooze_buf[64];
snprintf(snooze_buf, sizeof(snooze_buf), i18n_get(snooze_text, dialog), alarm_get_snooze_delay());
i18n_free(snooze_text, dialog);
dialog_set_text(dialog, snooze_buf);
dialog_set_icon(dialog, RESOURCE_ID_GENERIC_CONFIRMATION_LARGE);
dialog_set_background_color(dialog, GColorJaegerGreen);
dialog_set_timeout(dialog, DIALOG_TIMEOUT_DEFAULT);
return simple_dialog;
}
static void prv_edit_snooze_delay(ActionMenu *action_menu,
const ActionMenuItem *item,
void *context) {
alarm_set_snooze_delay((uintptr_t)item->action_data);
SimpleDialog *snooze_delay_dialog = prv_snooze_set_confirm_dialog();
action_menu_set_result_window(action_menu, (Window *)snooze_delay_dialog);
}
static void prv_toggle_enable_alarm_handler(ActionMenu *action_menu, const ActionMenuItem *item,
void *context) {
AlarmDetailData *data = (AlarmDetailData *) context;
alarm_set_enabled(data->alarm_id, !data->alarm_info.enabled);
if (data->alarm_editor_callback) {
data->alarm_editor_callback(EDITED, data->alarm_id, data->callback_context);
}
}
static void prv_toggle_smart_alarm_handler(ActionMenu *action_menu, const ActionMenuItem *item,
void *context) {
AlarmDetailData *data = context;
#if CAPABILITY_HAS_HEALTH_TRACKING
if (!data->alarm_info.is_smart && !activity_prefs_tracking_is_enabled()) {
// Notify about Health and keep the menu open
health_tracking_ui_feature_show_disabled();
return;
}
#endif
alarm_set_smart(data->alarm_id, !data->alarm_info.is_smart);
if (data->alarm_editor_callback) {
data->alarm_editor_callback(EDITED, data->alarm_id, data->callback_context);
}
}
static void prv_edit_time_handler(ActionMenu *action_menu,
const ActionMenuItem *item,
void *context) {
AlarmDetailData *data = (AlarmDetailData *) context;
alarm_editor_update_alarm_time(data->alarm_id,
data->alarm_info.is_smart ? AlarmType_Smart : AlarmType_Basic,
data->alarm_editor_callback, data->callback_context);
}
static void prv_edit_day_handler(ActionMenu *action_menu,
const ActionMenuItem *item,
void *context) {
AlarmDetailData *data = (AlarmDetailData *) context;
alarm_editor_update_alarm_days(data->alarm_id, data->alarm_editor_callback,
data->callback_context);
}
static void prv_delete_alarm_handler(ActionMenu *action_menu,
const ActionMenuItem *item,
void *context) {
AlarmDetailData *data = (AlarmDetailData *) context;
alarm_delete(data->alarm_id);
if (data->alarm_editor_callback) {
data->alarm_editor_callback(DELETED, data->alarm_id, data->callback_context);
}
}
static ActionMenuLevel *prv_create_main_menu(void) {
ActionMenuLevel *level = task_malloc(sizeof(ActionMenuLevel) +
DetailMenuItemIndexNum * sizeof(ActionMenuItem));
if (!level) return NULL;
*level = (ActionMenuLevel) {
.num_items = DetailMenuItemIndexNum,
.parent_level = NULL,
.display_mode = ActionMenuLevelDisplayModeWide,
};
return level;
}
static ActionMenuLevel *prv_create_snooze_menu(ActionMenuLevel *parent_level) {
ActionMenuLevel *level = task_malloc(sizeof(ActionMenuLevel) +
NUM_SNOOZE_MENU_ITEMS * sizeof(ActionMenuItem));
if (!level) return NULL;
*level = (ActionMenuLevel) {
.num_items = NUM_SNOOZE_MENU_ITEMS,
.parent_level = parent_level,
.display_mode = ActionMenuLevelDisplayModeWide,
};
return level;
}
void prv_cleanup_alarm_detail_menu(ActionMenu *action_menu,
const ActionMenuItem *item,
void *context) {
ActionMenuLevel *root_level = action_menu_get_root_level(action_menu);
AlarmDetailData *data = (AlarmDetailData *) context;
i18n_free_all(data);
task_free((void *)root_level->items[DetailMenuItemIndexSnooze].next_level);
task_free((void *)root_level);
task_free(data);
data = NULL;
}
void alarm_detail_window_push(AlarmId alarm_id, AlarmInfo *alarm_info,
AlarmEditorCompleteCallback alarm_editor_callback,
void *callback_context) {
AlarmDetailData* data = task_malloc_check(sizeof(AlarmDetailData));
*data = (AlarmDetailData) {
.alarm_id = alarm_id,
.alarm_info = *alarm_info,
.alarm_editor_callback = alarm_editor_callback,
.callback_context = callback_context,
.menu_config = {
.context = data,
.colors.background = ALARMS_APP_HIGHLIGHT_COLOR,
.did_close = prv_cleanup_alarm_detail_menu,
},
};
// Setup main menu items
ActionMenuLevel *main_menu = prv_create_main_menu();
main_menu->items[DetailMenuItemIndexDelete] = (ActionMenuItem) {
.label = i18n_get("Delete", data),
.perform_action = prv_delete_alarm_handler,
.action_data = data,
};
main_menu->items[DetailMenuItemIndexEnable] = (ActionMenuItem) {
.label = data->alarm_info.enabled ? i18n_get("Disable", data) : i18n_get("Enable", data),
.perform_action = prv_toggle_enable_alarm_handler,
.action_data = data,
};
main_menu->items[DetailMenuItemIndexChangeTime] = (ActionMenuItem) {
.label = i18n_get("Change Time", data),
.perform_action = prv_edit_time_handler,
.action_data = data,
};
main_menu->items[DetailMenuItemIndexChangeDays] = (ActionMenuItem) {
.label = i18n_get("Change Days", data),
.perform_action = prv_edit_day_handler,
.action_data = data,
};
#if CAPABILITY_HAS_HEALTH_TRACKING
main_menu->items[DetailMenuItemIndexConvertSmart] = (ActionMenuItem) {
.label = data->alarm_info.is_smart ? i18n_get("Convert to Basic Alarm", data) :
i18n_get("Convert to Smart Alarm", data),
.perform_action = prv_toggle_smart_alarm_handler,
.action_data = data,
};
#endif
main_menu->items[DetailMenuItemIndexSnooze] = (ActionMenuItem) {
.label = i18n_get("Snooze Delay", data),
.is_leaf = 0,
.next_level = prv_create_snooze_menu(main_menu),
};
main_menu->separator_index = DetailMenuItemIndexSnooze;
data->menu_config.root_level = main_menu;
// Setup snooze menu items
ActionMenuLevel *snooze_level = main_menu->items[DetailMenuItemIndexSnooze].next_level;
static const unsigned snooze_delays[NUM_SNOOZE_MENU_ITEMS] = {5, 10, 15, 30, 60};
static const char *snooze_delay_strs[NUM_SNOOZE_MENU_ITEMS] = {
i18n_noop("5 minutes"),
i18n_noop("10 minutes"),
i18n_noop("15 minutes"),
i18n_noop("30 minutes"),
i18n_noop("1 hour")
};
unsigned current_snooze_delay = alarm_get_snooze_delay();
for (int i = 0; i < NUM_SNOOZE_MENU_ITEMS; i++) {
snooze_level->items[i] = (ActionMenuItem) {
.label = i18n_get(snooze_delay_strs[i], data),
.perform_action = prv_edit_snooze_delay,
.action_data = (void *) (uintptr_t) snooze_delays[i],
};
if (current_snooze_delay == snooze_delays[i]) {
snooze_level->default_selected_item = i;
}
}
app_action_menu_open(&data->menu_config);
}

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "services/normal/alarms/alarm.h"
#include "alarm_editor.h"
void alarm_detail_window_push(AlarmId alarm_id, AlarmInfo *alarm_info,
AlarmEditorCompleteCallback alarm_editor_callback,
void *callback_context);

View file

@ -0,0 +1,625 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "alarm_editor.h"
#include "applib/pbl_std/timelocal.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/number_window.h"
#include "applib/ui/simple_menu_layer.h"
#include "applib/ui/time_selection_window.h"
#include "applib/ui/ui.h"
#include "apps/system_apps/settings/settings_option_menu.h"
#include "kernel/pbl_malloc.h"
#include "popups/health_tracking_ui.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/activity.h"
#include "services/normal/alarms/alarm.h"
#include "system/logging.h"
#include "system/passert.h"
#include <string.h>
#define ALARM_DAY_LIST_CELL_HEIGHT PBL_IF_RECT_ELSE(menu_cell_small_cell_height(), \
menu_cell_basic_cell_height())
typedef struct {
OptionMenu *alarm_type_menu;
AlarmType alarm_type;
TimeSelectionWindowData time_picker_window;
bool time_picker_was_completed;
Window day_picker_window;
MenuLayer day_picker_menu_layer;
bool day_picker_was_completed;
Window custom_day_picker_window;
MenuLayer custom_day_picker_menu_layer;
bool custom_day_picker_was_completed;
bool scheduled_days[DAYS_PER_WEEK];
GBitmap deselected_icon;
GBitmap selected_icon;
GBitmap checkmark_icon;
uint32_t current_checkmark_icon_resource_id;
bool show_check_something_first_text;
AlarmEditorCompleteCallback complete_callback;
void *callback_context;
AlarmId alarm_id;
int alarm_hour;
int alarm_minute;
AlarmKind alarm_kind;
bool creating_alarm;
} AlarmEditorData;
typedef enum DayPickerMenuItems {
DayPickerMenuItemsJustOnce = 0,
DayPickerMenuItemsWeekdays,
DayPickerMenuItemsWeekends,
DayPickerMenuItemsEveryday,
DayPickerMenuItemsCustom,
DayPickerMenuItemsNumItems,
} DayPickerMenuItems;
// Forward Declarations
static void prv_setup_custom_day_picker_window(AlarmEditorData *data);
static bool prv_is_custom_day_scheduled(AlarmEditorData *data);
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Helper functions
static void prv_remove_windows(AlarmEditorData *data) {
if (app_window_stack_contains_window(&data->time_picker_window.window)) {
app_window_stack_remove(&data->time_picker_window.window, false);
}
if (app_window_stack_contains_window(&data->day_picker_window)) {
app_window_stack_remove(&data->day_picker_window, false);
}
if (data->alarm_type_menu && app_window_stack_contains_window(&data->alarm_type_menu->window)) {
app_window_stack_remove(&data->alarm_type_menu->window, false);
}
}
static void prv_call_complete_cancelled_if_no_alarm(AlarmEditorData *data) {
if (data->alarm_id == ALARM_INVALID_ID && data->complete_callback) {
data->complete_callback(CANCELLED, data->alarm_id, data->callback_context);
}
}
static DayPickerMenuItems prv_alarm_kind_to_index(AlarmKind alarm_kind) {
switch (alarm_kind) {
case ALARM_KIND_EVERYDAY:
return DayPickerMenuItemsEveryday;
case ALARM_KIND_WEEKENDS:
return DayPickerMenuItemsWeekends;
case ALARM_KIND_WEEKDAYS:
return DayPickerMenuItemsWeekdays;
case ALARM_KIND_JUST_ONCE:
return DayPickerMenuItemsJustOnce;
case ALARM_KIND_CUSTOM:
return DayPickerMenuItemsCustom;
}
return DayPickerMenuItemsJustOnce;
}
static AlarmKind prv_index_to_alarm_kind(DayPickerMenuItems index) {
switch (index) {
case DayPickerMenuItemsWeekdays:
return ALARM_KIND_WEEKDAYS;
case DayPickerMenuItemsWeekends:
return ALARM_KIND_WEEKENDS;
case DayPickerMenuItemsEveryday:
return ALARM_KIND_EVERYDAY;
case DayPickerMenuItemsJustOnce:
return ALARM_KIND_JUST_ONCE;
case DayPickerMenuItemsCustom:
return ALARM_KIND_CUSTOM;
case DayPickerMenuItemsNumItems:
break;
}
return ALARM_KIND_EVERYDAY;
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Day Picker
static void prv_day_picker_window_unload(Window *window) {
AlarmEditorData *data = (AlarmEditorData*) window_get_user_data(window);
if (!data->day_picker_was_completed && data->time_picker_was_completed) {
// If we cancel the day picker go back to the time picker
data->time_picker_was_completed = false;
return;
}
if (data->creating_alarm) {
time_selection_window_deinit(&data->time_picker_window);
}
// Editing recurrence
menu_layer_deinit(&data->day_picker_menu_layer);
prv_remove_windows(data);
i18n_free_all(&data->day_picker_window);
task_free(data);
data = NULL;
}
static void prv_handle_selection(int index, void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
data->day_picker_was_completed = true;
data->alarm_kind = prv_index_to_alarm_kind(index);
if (data->creating_alarm) {
const AlarmInfo info = {
.hour = data->alarm_hour,
.minute = data->alarm_minute,
.kind = data->alarm_kind,
.is_smart = (data->alarm_type == AlarmType_Smart),
};
data->alarm_id = alarm_create(&info);
data->complete_callback(CREATED, data->alarm_id, data->callback_context);
app_window_stack_remove(&data->day_picker_window, true);
} else {
alarm_set_kind(data->alarm_id, data->alarm_kind);
data->complete_callback(EDITED, data->alarm_id, data->callback_context);
app_window_stack_pop(true);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Custom Day Picker
static void prv_custom_day_picker_window_unload(Window *window) {
AlarmEditorData *data = (AlarmEditorData*) window_get_user_data(window);
if (!data->custom_day_picker_was_completed) {
// If we cancel the custom day picker go back to the day picker
data->day_picker_was_completed = false;
i18n_free_all(&data->custom_day_picker_window);
return;
}
menu_layer_deinit(&data->custom_day_picker_menu_layer);
prv_remove_windows(data);
i18n_free_all(&data->custom_day_picker_window);
}
static void prv_handle_custom_day_selection(int index, void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
prv_setup_custom_day_picker_window(data);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Menu Layer Callbacks
static uint16_t prv_day_picker_get_num_sections(struct MenuLayer *menu_layer,
void *callback_context) {
return 1;
}
static uint16_t prv_day_picker_get_num_rows(struct MenuLayer *menu_layer,
uint16_t section_index,
void *callback_context) {
return DayPickerMenuItemsNumItems;
}
static int16_t prv_day_picker_get_cell_height(struct MenuLayer *menu_layer,
MenuIndex *cell_index,
void *callback_context) {
return ALARM_DAY_LIST_CELL_HEIGHT;
}
static void prv_day_picker_draw_row(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
AlarmKind kind = prv_index_to_alarm_kind(cell_index->row);
const bool all_caps = false;
const char *cell_text = alarm_get_string_for_kind(kind, all_caps);
menu_cell_basic_draw(ctx, cell_layer, i18n_get(cell_text, &data->day_picker_window), NULL, NULL);
}
static void prv_day_picker_handle_selection(MenuLayer *menu_layer, MenuIndex *cell_index,
void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
data->day_picker_was_completed = false;
if (cell_index->row == DayPickerMenuItemsCustom) {
prv_handle_custom_day_selection(cell_index->row, callback_context);
} else {
data->day_picker_was_completed = true;
prv_handle_selection(cell_index->row, callback_context);
}
}
static void prv_setup_day_picker_window(AlarmEditorData *data) {
window_init(&data->day_picker_window, WINDOW_NAME("Alarm Day Picker"));
window_set_user_data(&data->day_picker_window, data);
data->day_picker_window.window_handlers.unload = prv_day_picker_window_unload;
GRect bounds = data->day_picker_window.layer.bounds;
#if PBL_ROUND
bounds = grect_inset_internal(bounds, 0, STATUS_BAR_LAYER_HEIGHT);
#endif
menu_layer_init(&data->day_picker_menu_layer, &bounds);
menu_layer_set_callbacks(&data->day_picker_menu_layer, data, &(MenuLayerCallbacks) {
.get_num_sections = prv_day_picker_get_num_sections,
.get_num_rows = prv_day_picker_get_num_rows,
.get_cell_height = prv_day_picker_get_cell_height,
.draw_row = prv_day_picker_draw_row,
.select_click = prv_day_picker_handle_selection,
});
menu_layer_set_highlight_colors(&data->day_picker_menu_layer,
ALARMS_APP_HIGHLIGHT_COLOR,
GColorWhite);
menu_layer_set_click_config_onto_window(&data->day_picker_menu_layer, &data->day_picker_window);
layer_add_child(&data->day_picker_window.layer,
menu_layer_get_layer(&data->day_picker_menu_layer));
if (!alarm_get_kind(data->alarm_id, &data->alarm_kind)) {
data->alarm_kind = ALARM_KIND_JUST_ONCE;
}
menu_layer_set_selected_index(&data->day_picker_menu_layer,
(MenuIndex) { 0, prv_alarm_kind_to_index(data->alarm_kind) },
MenuRowAlignCenter, false);
}
static uint16_t prv_custom_day_picker_get_num_sections(struct MenuLayer *menu_layer,
void *callback_context) {
return 1;
}
static uint16_t prv_custom_day_picker_get_num_rows(struct MenuLayer *menu_layer,
uint16_t section_index, void *callback_context) {
return DAYS_PER_WEEK + 1;
}
static int16_t prv_custom_day_picker_get_cell_height(struct MenuLayer *menu_layer,
MenuIndex *cell_index,
void *callback_context) {
return ALARM_DAY_LIST_CELL_HEIGHT;
}
static void prv_custom_day_picker_draw_row(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
GBitmap *ptr_bitmap;
if (cell_index->row == 0) { // "completed selection" row
GRect box;
uint32_t new_resource_id = RESOURCE_ID_CHECKMARK_ICON_BLACK;
if (!prv_is_custom_day_scheduled(data)) {
// no days selected
if (menu_cell_layer_is_highlighted(cell_layer)) {
if (data->show_check_something_first_text) { // clicking "complete" when no days selected
box.size = GSize(cell_layer->bounds.size.w, ALARM_DAY_LIST_CELL_HEIGHT);
box.origin = GPoint(0, 4);
graphics_draw_text(ctx, i18n_get("Check something first.",
&data->custom_day_picker_window),
fonts_get_system_font(FONT_KEY_GOTHIC_18), box, GTextOverflowModeFill,
GTextAlignmentCenter, NULL);
return;
} else { // row highlighted and no days selected
new_resource_id = RESOURCE_ID_CHECKMARK_ICON_DOTTED;
}
}
}
if (new_resource_id != data->current_checkmark_icon_resource_id) {
data->current_checkmark_icon_resource_id = new_resource_id;
gbitmap_deinit(&data->checkmark_icon);
gbitmap_init_with_resource(&data->checkmark_icon, data->current_checkmark_icon_resource_id);
}
box.origin = GPoint((((cell_layer->bounds.size.w)/2)-((data->checkmark_icon.bounds.size.w)/2)),
(((cell_layer->bounds.size.h)/2)-((data->checkmark_icon.bounds.size.h)/2)));
box.size = data->checkmark_icon.bounds.size;
graphics_context_set_compositing_mode(ctx, GCompOpTint);
graphics_draw_bitmap_in_rect(ctx, &data->checkmark_icon, &box);
} else { // drawing a day of the week
const char *cell_text;
// List should start off with Monday
uint16_t day_index = cell_index->row % DAYS_PER_WEEK;
const struct lc_time_T *time_locale = time_locale_get();
cell_text = i18n_get(time_locale->weekday[day_index], &data->custom_day_picker_window);
if (data->scheduled_days[(cell_index->row) % DAYS_PER_WEEK]) {
ptr_bitmap = &data->selected_icon;
} else {
ptr_bitmap = &data->deselected_icon;
}
graphics_context_set_compositing_mode(ctx, GCompOpTint);
menu_cell_basic_draw_icon_right(ctx, cell_layer, cell_text, NULL, ptr_bitmap);
}
}
static bool prv_is_custom_day_scheduled(AlarmEditorData *data) {
for (unsigned int i = 0; i < sizeof(data->scheduled_days); i++) {
if (data->scheduled_days[i]) {
return true;
}
}
return false;
}
static void prv_custom_day_picker_handle_selection(MenuLayer *menu_layer, MenuIndex *cell_index,
void *callback_context) {
AlarmEditorData *data = (AlarmEditorData *)callback_context;
if (cell_index->row == 0) { // selected the "completed day selection" row
if (!prv_is_custom_day_scheduled(data)) { // clicking "complete" when no days are selected
data->show_check_something_first_text = true;
layer_mark_dirty(menu_layer_get_layer(menu_layer));
} else {
data->custom_day_picker_was_completed = true;
if (data->creating_alarm) {
const AlarmInfo info = {
.hour = data->alarm_hour,
.minute = data->alarm_minute,
.kind = ALARM_KIND_CUSTOM,
.scheduled_days = &data->scheduled_days,
.is_smart = (data->alarm_type == AlarmType_Smart),
};
data->alarm_id = alarm_create(&info);
data->complete_callback(CREATED, data->alarm_id, data->callback_context);
} else {
alarm_set_custom(data->alarm_id, data->scheduled_days);
data->complete_callback(EDITED, data->alarm_id, data->callback_context);
}
app_window_stack_pop(true);
}
} else { // selecting a day of the week
// day_of_week index starts from sunday, and printed list starts from monday
uint16_t day_of_week = (cell_index->row) % DAYS_PER_WEEK;
data->scheduled_days[day_of_week] = !data->scheduled_days[day_of_week]; // toggle selection
layer_mark_dirty(menu_layer_get_layer(menu_layer));
}
}
static void prv_custom_day_picker_selection_changed(MenuLayer *menu_layer, MenuIndex new_index,
MenuIndex old_index, void *callback_context) {
AlarmEditorData *data = (AlarmEditorData*) callback_context;
if (old_index.row == 0) {
data->show_check_something_first_text = false;
}
}
static void prv_setup_custom_day_picker_window(AlarmEditorData *data) {
window_init(&data->custom_day_picker_window, WINDOW_NAME("Alarm Custom Day Picker"));
window_set_user_data(&data->custom_day_picker_window, data);
data->custom_day_picker_window.window_handlers.unload = prv_custom_day_picker_window_unload;
GRect bounds = data->custom_day_picker_window.layer.bounds;
#if PBL_ROUND
bounds = grect_inset_internal(bounds, 0, STATUS_BAR_LAYER_HEIGHT);
#endif
menu_layer_init(&data->custom_day_picker_menu_layer, &bounds);
menu_layer_set_callbacks(&data->custom_day_picker_menu_layer, data, &(MenuLayerCallbacks) {
.get_num_sections = prv_custom_day_picker_get_num_sections,
.get_num_rows = prv_custom_day_picker_get_num_rows,
.get_cell_height = prv_custom_day_picker_get_cell_height,
.draw_row = prv_custom_day_picker_draw_row,
.select_click = prv_custom_day_picker_handle_selection,
.selection_changed = prv_custom_day_picker_selection_changed
});
menu_layer_set_highlight_colors(&data->custom_day_picker_menu_layer,
ALARMS_APP_HIGHLIGHT_COLOR,
GColorWhite);
menu_layer_set_click_config_onto_window(&data->custom_day_picker_menu_layer,
&data->custom_day_picker_window);
layer_add_child(&data->custom_day_picker_window.layer,
menu_layer_get_layer(&data->custom_day_picker_menu_layer));
gbitmap_init_with_resource(&data->selected_icon, RESOURCE_ID_CHECKBOX_ICON_CHECKED);
gbitmap_init_with_resource(&data->deselected_icon, RESOURCE_ID_CHECKBOX_ICON_UNCHECKED);
gbitmap_init_with_resource(&data->checkmark_icon, RESOURCE_ID_CHECKMARK_ICON_BLACK);
data->current_checkmark_icon_resource_id = RESOURCE_ID_CHECKMARK_ICON_BLACK;
app_window_stack_push(&data->custom_day_picker_window, true);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Time Picker
static void prv_time_picker_window_unload(Window *window) {
AlarmEditorData *data = (AlarmEditorData *)window_get_user_data(window);
if (data->creating_alarm) {
#if !CAPABILITY_HAS_HEALTH_TRACKING
prv_call_complete_cancelled_if_no_alarm(data);
#endif
return;
}
// Editing time
time_selection_window_deinit(&data->time_picker_window);
if (data->time_picker_was_completed) {
data->complete_callback(EDITED, data->alarm_id, data->callback_context);
}
i18n_free_all(data);
task_free(data);
data = NULL;
}
static void prv_time_picker_window_appear(Window *window) {
AlarmEditorData *data = (AlarmEditorData *)window_get_user_data(window);
const bool is_smart = (data->alarm_type == AlarmType_Smart);
const char *label = (!data->creating_alarm ? i18n_noop("Change Time") :
is_smart ? i18n_noop("New Smart Alarm") : i18n_noop("New Alarm"));
/// Displays as "Wake up between" then "8:00 AM - 8:30 AM" below on a separate line
const char *range_text = PBL_IF_RECT_ELSE(i18n_noop("Wake up between"),
/// Displays as "8:00 AM - 8:30 AM" then "Wake up interval" below on a separate line
i18n_noop("Wake up interval"));
const TimeSelectionWindowConfig config = {
.label = i18n_get(label, data),
.range = {
.update = true,
.text = is_smart ? i18n_get(range_text, data) : NULL,
.duration_m = SMART_ALARM_RANGE_S / SECONDS_PER_MINUTE,
.enabled = is_smart,
},
};
time_selection_window_configure(&data->time_picker_window, &config);
// Reset the selection layer to the first cell
data->time_picker_window.selection_layer.selected_cell_idx = 0;
}
static void prv_time_picker_complete(TimeSelectionWindowData *time_picker_window, void *cb_data) {
AlarmEditorData *data = (AlarmEditorData *) cb_data;
data->time_picker_was_completed = true;
data->alarm_hour = time_picker_window->time_data.hour;
data->alarm_minute = time_picker_window->time_data.minute;
if (data->creating_alarm) {
app_window_stack_push(&data->day_picker_window, true);
} else {
alarm_set_time(data->alarm_id, data->alarm_hour, data->alarm_minute);
app_window_stack_remove(&time_picker_window->window, true);
}
}
static void prv_setup_time_picker_window(AlarmEditorData *data) {
const TimeSelectionWindowConfig config = {
.color = ALARMS_APP_HIGHLIGHT_COLOR,
.callback = {
.update = true,
.complete = prv_time_picker_complete,
.context = data,
},
};
time_selection_window_init(&data->time_picker_window, &config);
window_set_user_data(&data->time_picker_window.window, data);
data->time_picker_window.window.window_handlers.unload = prv_time_picker_window_unload;
data->time_picker_window.window.window_handlers.appear = prv_time_picker_window_appear;
if (data->creating_alarm) {
time_selection_window_set_to_current_time(&data->time_picker_window);
} else {
int hour, minute;
alarm_get_hours_minutes(data->alarm_id, &hour, &minute);
data->time_picker_window.time_data.hour = hour;
data->time_picker_window.time_data.minute = minute;
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Type Picker
static void prv_type_menu_unload(OptionMenu *option_menu, void *context) {
AlarmEditorData *data = settings_option_menu_get_context(context);
prv_call_complete_cancelled_if_no_alarm(data);
data->alarm_type_menu = NULL;
}
static void prv_type_menu_select(OptionMenu *option_menu, int selection, void *context) {
AlarmEditorData *data = settings_option_menu_get_context(context);
data->alarm_type = selection;
#if CAPABILITY_HAS_HEALTH_TRACKING
if (selection == AlarmType_Smart && !activity_prefs_tracking_is_enabled()) {
// Notify about Health and keep the menu open
health_tracking_ui_feature_show_disabled();
return;
}
#endif
if (data->creating_alarm) {
app_window_stack_push(&data->time_picker_window.window, true);
} else {
alarm_set_smart(data->alarm_id, (data->alarm_type == AlarmType_Smart));
app_window_stack_remove(&option_menu->window, true);
}
}
static void prv_setup_type_menu_window(AlarmEditorData *data) {
const OptionMenuCallbacks callbacks = {
.select = prv_type_menu_select,
.unload = prv_type_menu_unload,
};
static const char *s_type_labels[AlarmTypeCount] = {
[AlarmType_Basic] = i18n_noop("Basic Alarm"),
[AlarmType_Smart] = i18n_noop("Smart Alarm"),
};
const char *title = i18n_get("New Alarm", data);
OptionMenu *option_menu = settings_option_menu_create(
title, OptionMenuContentType_Default, 0, &callbacks, ARRAY_LENGTH(s_type_labels),
false /* icons_enabled */, s_type_labels, data);
PBL_ASSERTN(option_menu);
data->alarm_type_menu = option_menu;
option_menu_set_highlight_colors(option_menu, ALARMS_APP_HIGHLIGHT_COLOR, GColorWhite);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Public API
Window* alarm_editor_create_new_alarm(AlarmEditorCompleteCallback complete_callback,
void *callback_context) {
AlarmEditorData* data = task_malloc_check(sizeof(AlarmEditorData));
*data = (AlarmEditorData) {
.alarm_id = ALARM_INVALID_ID,
.complete_callback = complete_callback,
.callback_context = callback_context,
.creating_alarm = true,
};
// Setup the windows
prv_setup_time_picker_window(data);
prv_setup_day_picker_window(data);
#if CAPABILITY_HAS_HEALTH_TRACKING
prv_setup_type_menu_window(data);
return &data->alarm_type_menu->window;
#else
return &data->time_picker_window.window;
#endif
}
void alarm_editor_update_alarm_time(AlarmId alarm_id, AlarmType alarm_type,
AlarmEditorCompleteCallback complete_callback,
void *callback_context) {
AlarmEditorData* data = task_malloc_check(sizeof(AlarmEditorData));
*data = (AlarmEditorData) {
.alarm_id = alarm_id,
.alarm_type = alarm_type,
.complete_callback = complete_callback,
.callback_context = callback_context,
};
prv_setup_time_picker_window(data);
app_window_stack_push(&data->time_picker_window.window, true);
}
void alarm_editor_update_alarm_days(AlarmId alarm_id, AlarmEditorCompleteCallback complete_callback,
void *callback_context) {
AlarmEditorData* data = task_malloc_check(sizeof(AlarmEditorData));
*data = (AlarmEditorData) {
.alarm_id = alarm_id,
.complete_callback = complete_callback,
.callback_context = callback_context,
};
alarm_get_kind(alarm_id, &data->alarm_kind);
if (data->alarm_kind == ALARM_KIND_CUSTOM) {
alarm_get_custom_days(alarm_id, data->scheduled_days);
}
prv_setup_day_picker_window(data);
app_window_stack_push(&data->day_picker_window, true);
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "services/normal/alarms/alarm.h"
#include "applib/ui/window.h"
typedef enum {
CREATED,
DELETED,
EDITED,
CANCELLED
} AlarmEditorResult;
typedef void (*AlarmEditorCompleteCallback)(AlarmEditorResult result, AlarmId id,
void *callback_context);
Window* alarm_editor_create_new_alarm(AlarmEditorCompleteCallback editor_complete_callback,
void *callback_context);
void alarm_editor_update_alarm_time(AlarmId alarm_id, AlarmType alarm_type,
AlarmEditorCompleteCallback editor_complete_callback,
void *callback_context);
void alarm_editor_update_alarm_days(AlarmId alarm_id,
AlarmEditorCompleteCallback editor_complete_callback,
void *callback_context);

View file

@ -0,0 +1,492 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "alarms.h"
#include "alarm_detail.h"
#include "alarm_editor.h"
#include "applib/app.h"
#include "applib/graphics/gtypes.h"
#include "applib/preferred_content_size.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/dialogs/dialog.h"
#include "applib/ui/dialogs/expandable_dialog.h"
#include "applib/ui/dialogs/simple_dialog.h"
#include "applib/ui/ui.h"
#include "kernel/pbl_malloc.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "services/common/clock.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/alarms/alarm.h"
#include "services/normal/timeline/timeline.h"
#include "shell/system_theme.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/string.h"
#include "util/time/time.h"
#include "util/trig.h"
#include <stdio.h>
#include <string.h>
// Alarms app versions
// 0: Initial version or never opened
// 1: Smart alarms
#define CURRENT_ALARMS_APP_VERSION 1
typedef struct {
ListNode node;
AlarmId id;
AlarmInfo info;
bool scheduled_days[DAYS_PER_WEEK];
} AlarmNode;
typedef struct AlarmsAppData {
Window window;
MenuLayer menu_layer;
StatusBarLayer status_layer;
GBitmap plus_icon;
#if CAPABILITY_HAS_HEALTH_TRACKING
GBitmap smart_alarm_icon;
#endif
AlarmNode *alarm_list_head;
MenuIndex selected_index;
bool show_limit_reached_text;
bool can_schedule_alarm;
uint32_t current_plus_icon_resource_id;
EventServiceInfo alarm_event_info;
} AlarmsAppData;
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Alarm list functions
static int prv_alarm_comparator(void *a, void *b) {
AlarmNode *alarm_a = (AlarmNode*) a;
AlarmNode *alarm_b = (AlarmNode*) b;
// Sort by alarm time, with 12:00AM being the starting point
if (alarm_a->info.hour > alarm_b->info.hour) {
return true;
}
if (alarm_a->info.hour == alarm_b->info.hour && alarm_a->info.minute > alarm_b->info.minute) {
return true;
}
return false;
}
static void prv_clear_alarm_list(AlarmsAppData* data) {
while (data->alarm_list_head) {
AlarmNode* old_head = data->alarm_list_head;
data->alarm_list_head = (AlarmNode*) list_pop_head((ListNode *) old_head);
task_free(old_head);
old_head = NULL;
}
}
static void prv_add_alarm_to_list(AlarmId id, const AlarmInfo *info, void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
AlarmNode *new_node = task_malloc_check(sizeof(AlarmNode));
list_init((ListNode*) new_node);
new_node->id = id;
new_node->info = *info;
memcpy(&new_node->scheduled_days, info->scheduled_days, sizeof(new_node->scheduled_days));
new_node->info.scheduled_days = &new_node->scheduled_days;
data->alarm_list_head = (AlarmNode *)list_sorted_add((ListNode *)data->alarm_list_head,
(ListNode *)new_node,
prv_alarm_comparator,
false);
}
static void prv_update_alarm_list(AlarmsAppData *data) {
prv_clear_alarm_list(data);
alarm_for_each(prv_add_alarm_to_list, data);
data->can_schedule_alarm = alarm_can_schedule();
}
static bool prv_are_alarms_scheduled(AlarmsAppData *data) {
return (list_count((ListNode*) data->alarm_list_head) > 0);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! General helper functions
static void prv_show_deleted_dialog(void) {
SimpleDialog *simple_dialog = simple_dialog_create("AlarmDelete");
Dialog *dialog = simple_dialog_get_dialog(simple_dialog);
const char *delete_text = i18n_noop("Alarm Deleted");
dialog_set_text(dialog, i18n_get(delete_text, dialog));
i18n_free(delete_text, dialog);
dialog_set_icon(dialog, RESOURCE_ID_RESULT_SHREDDED_LARGE);
dialog_set_background_color(dialog, ALARMS_APP_HIGHLIGHT_COLOR);
dialog_set_timeout(dialog, DIALOG_TIMEOUT_DEFAULT);
app_simple_dialog_push(simple_dialog);
}
static int prv_get_list_idx_of_alarm_id(AlarmsAppData* data, AlarmId id) {
if (id == ALARM_INVALID_ID) {
return 0;
}
AlarmNode *cur_node = data->alarm_list_head;
// Starting at one because of the "+" cell
int list_idx = 1;
while (cur_node) {
if (cur_node->id == id) {
return list_idx;
}
list_idx++;
cur_node = (AlarmNode* )list_get_next((ListNode*) cur_node);
}
return 0;
}
static void prv_update_menu_layer(AlarmsAppData* data, AlarmId select_alarm) {
MenuIndex selected_menu_index = {0, prv_get_list_idx_of_alarm_id(data, select_alarm)};
menu_layer_reload_data(&data->menu_layer);
menu_layer_set_selected_index(&data->menu_layer, selected_menu_index,
MenuRowAlignCenter, false);
}
static void prv_handle_alarm_editor_complete(AlarmEditorResult result, AlarmId id,
void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
if (result == CANCELLED && !prv_are_alarms_scheduled(data)) {
// In the case the user had no alarms set, and didn't finish creating one.
// We want to exit the app without showing an empty alarm list
app_window_stack_remove(&data->window, true);
} else if (result == DELETED) {
prv_update_alarm_list(data);
if (!prv_are_alarms_scheduled(data)) {
// The user deleted their last alarm, show a dialog and setup the create new alarm screen.
// We don't want to show an empty alarm list
prv_show_deleted_dialog();
Window *editor = alarm_editor_create_new_alarm(prv_handle_alarm_editor_complete, data);
app_window_stack_insert_next(editor);
} else {
prv_update_menu_layer(data, ALARM_INVALID_ID);
}
} else { // Created / Edited
prv_update_alarm_list(data);
prv_update_menu_layer(data, id);
}
}
static void prv_handle_alarm_event(PebbleEvent *e, void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
prv_update_alarm_list(data);
}
static void prv_create_new_alarm(AlarmsAppData* data) {
Window *editor = alarm_editor_create_new_alarm(prv_handle_alarm_editor_complete, data);
app_window_stack_push(editor, true);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Menu Layer Callbacks
static bool prv_is_add_alarm_cell(MenuIndex *cell_index) {
return cell_index->row == 0;
}
static uint16_t prv_alarm_list_get_num_sections_callback(struct MenuLayer *menu_layer,
void *callback_context) {
return 1;
}
static uint16_t prv_alarm_list_get_num_rows_callback(struct MenuLayer *menu_layer,
uint16_t section_index,
void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
// Number of alarms + the add alarm header
return list_count((ListNode*) data->alarm_list_head) + 1;
}
static int16_t prv_alarm_list_get_cell_height_callback(struct MenuLayer *menu_layer,
MenuIndex *cell_index,
void *callback_context) {
return menu_cell_basic_cell_height();
}
static void prv_alarm_list_draw_row_callback(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
if (prv_is_add_alarm_cell(cell_index)) {
GRect box;
uint32_t new_bitmap_resource = RESOURCE_ID_PLUS_ICON_BLACK;
if (!data->can_schedule_alarm) { // alarm limit reached
if (menu_cell_layer_is_highlighted(cell_layer)) {
if (data->show_limit_reached_text) {
// Trying to add a new alarm when list is already full
const GFont font =
system_theme_get_font_for_default_size(TextStyleFont_MenuCellSubtitle);
box = GRect(0, 0, cell_layer->bounds.size.w, fonts_get_font_height(font));
const char *text = i18n_get("Limit reached.", data);
box.size = graphics_text_layout_get_max_used_size(ctx, text, font, box,
GTextOverflowModeTrailingEllipsis,
GTextAlignmentCenter, NULL);
grect_align(&box, &cell_layer->bounds, GAlignCenter, true /* clip */);
box.origin.y -= fonts_get_font_cap_offset(font);
graphics_draw_text(ctx, text, font, box,
GTextOverflowModeFill, GTextAlignmentCenter, NULL);
return;
} else { // "add alarm" cell highlighted
new_bitmap_resource = RESOURCE_ID_PLUS_ICON_DOTTED;
}
} else { // "add alarm" cell not highlighted
// Have to manually override the tint color as we're using a color that differs
// from the ones the MenuLayer uses.
graphics_context_set_tint_color(ctx, GColorLightGray);
}
}
if (new_bitmap_resource != data->current_plus_icon_resource_id) {
// Change the icon to the dotted one
data->current_plus_icon_resource_id = new_bitmap_resource;
gbitmap_deinit(&data->plus_icon);
gbitmap_init_with_resource(&data->plus_icon, data->current_plus_icon_resource_id);
}
box.origin = GPoint((cell_layer->bounds.size.w - data->plus_icon.bounds.size.w) / 2,
(cell_layer->bounds.size.h - data->plus_icon.bounds.size.h) / 2);
box.size = data->plus_icon.bounds.size;
graphics_context_set_compositing_mode(ctx, GCompOpTint);
graphics_draw_bitmap_in_rect(ctx, &data->plus_icon, &box);
return;
}
AlarmNode *node = (AlarmNode*) list_get_at((ListNode*)data->alarm_list_head, cell_index->row - 1);
// Format 1: 10:34 AM
// Format 2: 14:56
char alarm_time_text[9];
clock_format_time(alarm_time_text, sizeof(alarm_time_text),
node->info.hour, node->info.minute, true);
const char *enabled = node->info.enabled ? i18n_get("ON", data) : i18n_get("OFF", data);
graphics_context_set_compositing_mode(ctx, GCompOpTint);
// If the alarm is not smart, use the icon as spacing but don't render it.
// Otherwise if the alarm is smart draw according to the menu highlight.
graphics_context_set_tint_color(ctx, !node->info.is_smart ? GColorClear :
(cell_layer->is_highlighted ? GColorWhite : GColorBlack));
char alarm_day_text[32] = {0};
MenuCellLayerConfig config = {
.title = alarm_time_text,
.value = enabled,
#if CAPABILITY_HAS_HEALTH_TRACKING
.icon = &data->smart_alarm_icon,
.icon_align = MenuCellLayerIconAlign_TopLeft,
.icon_box_model = &(GBoxModel) { .offset = { 0, 5 }, .margin = { 6, 0 } },
.icon_form_fit = true,
.horizontal_inset = PBL_IF_ROUND_ELSE(-6, 0),
#endif
.overflow_mode = GTextOverflowModeTrailingEllipsis,
};
if (node->info.kind != ALARM_KIND_CUSTOM) {
const bool all_caps = false;
config.subtitle = i18n_get(alarm_get_string_for_kind(node->info.kind, all_caps), data);
} else {
alarm_get_string_for_custom(node->scheduled_days, alarm_day_text);
config.subtitle = alarm_day_text;
}
menu_cell_layer_draw(ctx, cell_layer, &config);
}
static void prv_alarm_list_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index,
void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
if (prv_is_add_alarm_cell(cell_index)) {
if (!data->can_schedule_alarm) {
data->show_limit_reached_text = true;
layer_mark_dirty(menu_layer_get_layer(&data->menu_layer));
} else {
prv_create_new_alarm(data);
}
return;
}
// Minus 1 because of the "add alarm" cell
AlarmNode *node =
(AlarmNode *)list_get_at((ListNode *)data->alarm_list_head, cell_index->row - 1);
alarm_detail_window_push(node->id, &node->info, prv_handle_alarm_editor_complete, data);
}
static void prv_alarm_list_selection_changed_callback(MenuLayer *menu_layer, MenuIndex new_index,
MenuIndex old_index, void *callback_context) {
AlarmsAppData *data = (AlarmsAppData *)callback_context;
if (prv_is_add_alarm_cell(&old_index)) {
data->show_limit_reached_text = false;
}
}
#if CAPABILITY_HAS_HEALTH_TRACKING
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Smart Alarm first use dialog
typedef struct FirstUseDialog {
ExpandableDialog dialog;
AlarmsAppData *data;
} FirstUseDialog;
static void prv_alarms_app_opened_click_handler(ClickRecognizerRef recognizer, void *context) {
ExpandableDialog *expandable_dialog = context;
expandable_dialog_pop(expandable_dialog);
}
static void prv_push_alarms_app_opened_dialog(AlarmsAppData *data) {
const char *first_use_text = i18n_get(
"Let us wake you in your lightest sleep so you're fully refreshed! "
"Smart Alarm wakes you up to 30min before your alarm.", data);
const char *header = i18n_get("Smart Alarm", data);
ExpandableDialog *expandable_dialog = expandable_dialog_create_with_params(
header, RESOURCE_ID_SMART_ALARM_TINY, first_use_text,
GColorBlack, GColorWhite, NULL, RESOURCE_ID_ACTION_BAR_ICON_CHECK,
prv_alarms_app_opened_click_handler);
expandable_dialog_set_action_bar_background_color(expandable_dialog, ALARMS_APP_HIGHLIGHT_COLOR);
expandable_dialog_set_header(expandable_dialog, header);
#if defined(PBL_ROUND)
expandable_dialog_set_header_font(expandable_dialog,
fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
#endif
// Show immediately since this is the first window and there is already a compositor animation
app_window_stack_push(&expandable_dialog->dialog.window, false /* animated */);
}
#endif
///////////////////////////////////////////////////////////////////////////////////////////////////
//! App boilerplate
static void prv_handle_init(void) {
AlarmsAppData *data = app_malloc_check(sizeof(*data));
*data = (AlarmsAppData) {{ .user_data = NULL }};
Window *window = &data->window;
window_init(window, WINDOW_NAME("Alarms"));
window_set_user_data(window, data);
data->alarm_list_head = NULL;
// Alarm list must be updated before menu layer is initialized
prv_update_alarm_list(data);
const GRect bounds = grect_inset(data->window.layer.bounds,
GEdgeInsets(STATUS_BAR_LAYER_HEIGHT, 0,
PBL_IF_ROUND_ELSE(STATUS_BAR_LAYER_HEIGHT, 0), 0));
menu_layer_init(&data->menu_layer, &bounds);
menu_layer_set_callbacks(&data->menu_layer, data, &(MenuLayerCallbacks) {
.get_num_sections = prv_alarm_list_get_num_sections_callback,
.get_num_rows = prv_alarm_list_get_num_rows_callback,
.get_cell_height = prv_alarm_list_get_cell_height_callback,
.draw_row = prv_alarm_list_draw_row_callback,
.select_click = prv_alarm_list_select_callback,
.selection_changed = prv_alarm_list_selection_changed_callback
});
menu_layer_set_highlight_colors(&data->menu_layer, ALARMS_APP_HIGHLIGHT_COLOR, GColorWhite);
menu_layer_set_click_config_onto_window(&data->menu_layer, &data->window);
layer_add_child(&data->window.layer, menu_layer_get_layer(&data->menu_layer));
status_bar_layer_init(&data->status_layer);
status_bar_layer_set_colors(&data->status_layer, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack),
PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite));
status_bar_layer_set_separator_mode(&data->status_layer, StatusBarLayerSeparatorModeNone);
layer_add_child(&data->window.layer, status_bar_layer_get_layer(&data->status_layer));
#if CAPABILITY_HAS_HEALTH_TRACKING
gbitmap_init_with_resource(&data->smart_alarm_icon, RESOURCE_ID_SMART_ALARM_ICON_BLACK);
#endif
gbitmap_init_with_resource(&data->plus_icon, RESOURCE_ID_PLUS_ICON_BLACK);
data->current_plus_icon_resource_id = RESOURCE_ID_PLUS_ICON_BLACK;
app_state_set_user_data(data);
data->alarm_event_info = (EventServiceInfo) {
.type = PEBBLE_ALARM_CLOCK_EVENT,
.handler = prv_handle_alarm_event,
.context = data,
};
event_service_client_subscribe(&data->alarm_event_info);
if (prv_are_alarms_scheduled(data)) {
app_window_stack_push(&data->window, true);
menu_layer_set_selected_index(&data->menu_layer, MenuIndex(0, 1),
PBL_IF_RECT_ELSE(MenuRowAlignNone, MenuRowAlignCenter), false);
} else {
Window *editor = alarm_editor_create_new_alarm(prv_handle_alarm_editor_complete, data);
app_window_stack_push(editor, true);
app_window_stack_insert_next(&data->window);
}
#if CAPABILITY_HAS_HEALTH_TRACKING
uint32_t version = alarm_prefs_get_alarms_app_opened();
if (version == 0) {
prv_push_alarms_app_opened_dialog(data);
}
alarm_prefs_set_alarms_app_opened(CURRENT_ALARMS_APP_VERSION);
#endif
}
static void prv_handle_deinit(void) {
AlarmsAppData *data = app_state_get_user_data();
status_bar_layer_deinit(&data->status_layer);
menu_layer_deinit(&data->menu_layer);
i18n_free_all(data);
prv_clear_alarm_list(data);
event_service_client_unsubscribe(&data->alarm_event_info);
app_free(data);
}
static void s_main(void) {
prv_handle_init();
app_event_loop();
prv_handle_deinit();
}
const PebbleProcessMd* alarms_app_get_info() {
static const PebbleProcessMdSystem s_alarms_app_info = {
.common = {
.main_func = s_main,
.uuid = UUID_ALARMS_DATA_SOURCE,
},
.name = i18n_noop("Alarms"),
#if CAPABILITY_HAS_APP_GLANCES
.icon_resource_id = RESOURCE_ID_ALARM_CLOCK_TINY,
#elif PLATFORM_TINTIN
.icon_resource_id = RESOURCE_ID_MENU_LAYER_ALARMS_APP_ICON,
#endif
};
return (const PebbleProcessMd*) &s_alarms_app_info;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* alarms_app_get_info();

View file

@ -0,0 +1,335 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "app_fetch_ui.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "applib/app.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/progress_window.h"
#include "applib/ui/ui.h"
#include "drivers/battery.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "process_management/app_manager.h"
#include "process_management/worker_manager.h"
#include "process_state/app_state/app_state.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/app_fetch_endpoint.h"
#include "services/normal/timeline/timeline_resources.h"
#include "apps/system_apps/timeline/peek_layer.h"
#include "shell/normal/watchface.h"
#include "shell/shell.h"
#include "shell/system_app_state_machine.h"
#include "services/common/compositor/compositor_transitions.h"
#include "system/logging.h"
#include "system/passert.h"
#include "services/common/evented_timer.h"
#define FAIL_PAUSE_MS 1000
#define SCROLL_OUT_MS 250
#define BAR_HEIGHT 6
#define BAR_WIDTH 80
#define BAR_TO_TRANS_MS 160
#define TRANS_TO_DOT_MS 90
#define DOT_TRANSITION_RADIUS 13
#define DOT_COMPOSITOR_RADIUS 7
#define DOT_OFFSET 25
#define UPDATE_INTERVAL 200
#define UPDATE_AMOUNT 2
#define FAILURE_PERCENT 15
#define INITIAL_PERCENT 0
//! App data
typedef struct {
//! UI
ProgressWindow window;
//! App fetch result
AppFetchResult result;
//! Data
AppInstallEntry install_entry;
AppFetchUIArgs next_app_args;
EventServiceInfo fetch_event_info;
EventServiceInfo connect_event_info;
bool failed;
} AppFetchUIData;
static void prv_set_progress(AppFetchUIData *data, int16_t progress) {
progress_window_set_progress(&data->window, progress);
}
// Launch the desired app
static void prv_app_fetch_launch_app(AppFetchUIData *data) {
// Let's launch the application we just fetched.
PBL_LOG(LOG_LEVEL_DEBUG, "App Fetch: Putting launch event");
// if this was launched by the phone, it's probably a new install
if ((data->next_app_args.common.reason == APP_LAUNCH_PHONE) &&
!battery_is_usb_connected()) {
vibes_short_pulse();
}
// Allocate and inialize the data that would have been sent to the app originally before the
// fetch request.
PebbleLaunchAppEventExtended *ext = kernel_malloc_check(sizeof(PebbleLaunchAppEventExtended));
*ext = (PebbleLaunchAppEventExtended) {
.common = data->next_app_args.common,
.wakeup = data->next_app_args.wakeup_info
};
#if PLATFORM_TINTIN
ext->common.transition = compositor_app_slide_transition_get(true /* slide to right */);
#else
ext->common.transition = compositor_dot_transition_app_fetch_get();
#endif
if ((data->next_app_args.common.reason == APP_LAUNCH_WAKEUP) &&
(data->next_app_args.common.args != NULL)) {
ext->common.args = &data->next_app_args.wakeup_info;
}
PebbleEvent launch_event = {
.type = PEBBLE_APP_LAUNCH_EVENT,
.launch_app = {
.id = data->next_app_args.app_id,
.data = ext
}
};
event_put(&launch_event);
}
///////////////////////////////
// Animation Related Functions
///////////////////////////////
static void prv_remote_comm_session_event_handler(PebbleEvent *event, void *context) {
AppFetchUIData *data = app_state_get_user_data();
if (event->bluetooth.comm_session_event.is_open &&
event->bluetooth.comm_session_event.is_system) {
progress_window_pop(&data->window);
}
}
static void prv_set_progress_failure(AppFetchUIData *data) {
uint32_t icon;
const char *message;
switch (data->result) {
case AppFetchResultNoBluetooth:
icon = TIMELINE_RESOURCE_WATCH_DISCONNECTED;
message = i18n_get("Not connected", data);
// Subscribe to the BT remote app connect event
data->connect_event_info = (EventServiceInfo) {
.type = PEBBLE_COMM_SESSION_EVENT,
.handler = prv_remote_comm_session_event_handler
};
event_service_client_subscribe(&data->connect_event_info);
break;
case AppFetchResultNoData:
icon = TIMELINE_RESOURCE_CHECK_INTERNET_CONNECTION;
#if PBL_ROUND
// TODO PBL-28730: Fix peek layer so it does its own line wrapping
message = i18n_get("No internet\nconnection", data);
#else
message = i18n_get("No internet connection", data);
#endif
break;
case AppFetchResultIncompatibleJSFailure:
// TODO: PBL-39752 make this a more expressive error message with a call to action
icon = TIMELINE_RESOURCE_GENERIC_WARNING;
message = i18n_get("Incompatible JS", data);
break;
case AppFetchResultGeneralFailure:
case AppFetchResultUUIDInvalid:
case AppFetchResultPutBytesFailure:
case AppFetchResultTimeoutError:
case AppFetchResultPhoneBusy:
default:
icon = TIMELINE_RESOURCE_GENERIC_WARNING;
message = i18n_get("Failed", data);
break;
}
progress_window_set_result_failure(&data->window, icon, message,
PROGRESS_WINDOW_DEFAULT_FAILURE_DELAY_MS);
if (!battery_is_usb_connected()) {
vibes_short_pulse();
}
}
static void prv_progress_window_finished(ProgressWindow *window, bool success, void *context) {
AppFetchUIData *data = context;
if (success) {
prv_app_fetch_launch_app(data);
}
}
////////////////////////////
// Internal Helper Functions
////////////////////////////
//! Used to clean up the application's data before exiting
static void prv_app_fetch_cleanup(AppFetchUIData *data) {
PBL_LOG(LOG_LEVEL_DEBUG, "App Fetch: prv_app_fetch_cleanup");
event_service_client_unsubscribe(&data->fetch_event_info);
event_service_client_unsubscribe(&data->connect_event_info);
}
//! Used when the app fetch process has failed
static void prv_app_fetch_failure(AppFetchUIData *data, uint8_t error_code) {
PBL_LOG(LOG_LEVEL_WARNING, "App Fetch: prv_app_fetch_failure: %d", error_code);
if (error_code == AppFetchResultUserCancelled) {
app_window_stack_pop(true);
}
data->result = error_code;
if ((watchface_get_default_install_id() == data->install_entry.install_id) &&
app_install_entry_is_watchface(&data->install_entry)) {
// We failed to fetch a watchface and it was our default.
// Invalidate it and it will be reassigned to one that exists next time around.
PBL_LOG(LOG_LEVEL_WARNING, "Default watchface fetch failed, setting INVALID as default");
watchface_set_default_install_id(INSTALL_ID_INVALID);
} else if ((worker_manager_get_default_install_id() == data->install_entry.install_id) &&
app_install_entry_has_worker(&data->install_entry)) {
// We failed to fetch a worker and it was our default.
// Invalidate it and it will be reassigned to one that is launched next.
PBL_LOG(LOG_LEVEL_WARNING, "Default worker fetch failed, setting INVALID as default");
worker_manager_set_default_install_id(INSTALL_ID_INVALID);
}
data->failed = true;
prv_set_progress_failure(data);
prv_app_fetch_cleanup(data);
}
//! App Fetch handler. Used for keeping track of progress and cleanup events
static void prv_app_fetch_event_handler(PebbleEvent *event, void *context) {
AppFetchUIData *data = app_state_get_user_data();
PebbleAppFetchEvent *af_event = (PebbleAppFetchEvent *) event;
// We have starting the App Fetch Process
if (af_event->type == AppFetchEventTypeStart) {
PBL_LOG(LOG_LEVEL_DEBUG, "App Fetch: Got the start event");
// We have received a new progress event
} else if (af_event->type == AppFetchEventTypeProgress) {
progress_window_set_progress(&data->window, af_event->progress_percent);
// We have finished the app fetch. Launching
} else if (af_event->type == AppFetchEventTypeFinish) {
progress_window_set_result_success(&data->window);
prv_app_fetch_cleanup(data);
// We received an error. Fail
} else if (af_event->type == AppFetchEventTypeError) {
prv_app_fetch_failure(data, af_event->error_code);
}
}
// TODO: Use appropriate transitions to and from watchfaces or apps
static void prv_click_handler(ClickRecognizerRef recognizer, Window *window) {
AppFetchUIData *data = app_state_get_user_data();
if (data->failed) {
app_window_stack_pop(true);
} else {
app_fetch_cancel(data->install_entry.install_id);
}
}
static void config_provider(void *context) {
window_single_click_subscribe(BUTTON_ID_BACK, (ClickHandler) prv_click_handler);
window_single_click_subscribe(BUTTON_ID_UP, (ClickHandler) prv_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, (ClickHandler) prv_click_handler);
window_single_click_subscribe(BUTTON_ID_BACK, (ClickHandler) prv_click_handler);
}
static void handle_init(void) {
AppFetchUIData* data = app_zalloc_check(sizeof(AppFetchUIData));
app_state_set_user_data(data);
// get app args, copy them to app memory, and free the kernel buffer
AppFetchUIArgs *temp_fetch_args =
(AppFetchUIArgs *)process_manager_get_current_process_args();
memcpy(&data->next_app_args, temp_fetch_args, sizeof(AppFetchUIArgs));
kernel_free(temp_fetch_args);
// Create and set up window
progress_window_init(&data->window);
progress_window_set_callbacks(&data->window, (ProgressWindowCallbacks) {
.finished = prv_progress_window_finished,
}, data);
window_set_click_config_provider((Window *)&data->window, config_provider);
// retrieve data about the AppInstallId given
if (!app_install_get_entry_for_install_id(data->next_app_args.app_id, &data->install_entry)) {
PBL_LOG(LOG_LEVEL_ERROR, "App Fetch: Error getting entry for id: %"PRIu32"",
data->next_app_args.app_id);
return;
}
AppFetchError prev_error = app_fetch_get_previous_error();
if ((prev_error.id == data->next_app_args.app_id) &&
(prev_error.error != AppFetchResultSuccess)) {
prv_app_fetch_failure(data, prev_error.error);
prv_set_progress(data, FAILURE_PERCENT);
}
// subscribe to PutBytes events
data->fetch_event_info = (EventServiceInfo) {
.type = PEBBLE_APP_FETCH_EVENT,
.handler = prv_app_fetch_event_handler
};
event_service_client_subscribe(&data->fetch_event_info);
app_progress_window_push(&data->window);
}
static void handle_deinit(void) {
AppFetchUIData* data = app_state_get_user_data();
prv_app_fetch_cleanup(data);
progress_window_deinit(&data->window);
app_free(data);
i18n_free_all(data);
}
static void s_main(void) {
handle_init();
app_event_loop();
handle_deinit();
}
const PebbleProcessMd *app_fetch_ui_get_app_info() {
static const PebbleProcessMdSystem s_app_md = {
.common = {
.main_func = s_main,
.visibility = ProcessVisibilityHidden,
// UUID: 674271bc-f4fa-4536-97f3-8849a5ba75a4
.uuid = {0x67, 0x42, 0x71, 0xbc, 0xf4, 0xfa, 0x45, 0x36,
0x97, 0xf3, 0x88, 0x49, 0xa5, 0xba, 0x75, 0xa4},
},
.name = "App Fetch",
};
return (const PebbleProcessMd*) &s_app_md;
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/app_install_types.h"
#include "process_management/launch_config.h"
#include "process_management/pebble_process_md.h"
#include "services/common/compositor/compositor.h"
#include "services/normal/wakeup.h"
#include <stdbool.h>
typedef struct AppFetchUIArgs {
LaunchConfigCommon common;
WakeupInfo wakeup_info;
AppInstallId app_id;
bool forcefully; //! whether to launch forcefully or not
} AppFetchUIArgs;
//! Used to launch the app_fetch_ui application
const PebbleProcessMd *app_fetch_ui_get_app_info(void);

View file

@ -0,0 +1,94 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "applib/app.h"
#include "applib/graphics/graphics.h"
#include "applib/ui/window_private.h"
#include "applib/ui/app_window_stack.h"
#include "kernel/pbl_malloc.h"
#include "process_management/pebble_process_md.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "system/passert.h"
typedef struct BatteryCriticalAppData {
Window window;
Layer layer;
GBitmap bitmap;
} BatteryCriticalAppData;
static void update_proc(Layer* layer, GContext* ctx) {
BatteryCriticalAppData *app_data = app_state_get_user_data();
GRect low_battery_bounds = {
.origin = {
.x = (DISP_COLS - app_data->bitmap.bounds.size.w) / 2,
.y = (DISP_ROWS - app_data->bitmap.bounds.size.h),
},
.size = app_data->bitmap.bounds.size,
};
graphics_draw_bitmap_in_rect(ctx, &app_data->bitmap, &low_battery_bounds);
}
static void handle_init(void) {
BatteryCriticalAppData* data = app_malloc_check(sizeof(BatteryCriticalAppData));
gbitmap_init_with_resource(&data->bitmap, RESOURCE_ID_BATTERY_ICON_CHARGE);
app_state_set_user_data(data);
Window *window = &data->window;
window_init(window, WINDOW_NAME("Battery Critical"));
window_set_overrides_back_button(window, true);
layer_init(&data->layer, &window_get_root_layer(&data->window)->frame);
layer_set_update_proc(&data->layer, update_proc);
layer_add_child(window_get_root_layer(&data->window), &data->layer);
const bool animated = false;
app_window_stack_push(window, animated);
}
static void handle_deinit(void) {
BatteryCriticalAppData* app_data = app_state_get_user_data();
gbitmap_deinit(&app_data->bitmap);
app_free(app_data);
}
static void s_main(void) {
handle_init();
app_event_loop();
handle_deinit();
}
const PebbleProcessMd* battery_critical_get_app_info() {
static const PebbleProcessMdSystem s_app_md = {
.common = {
.main_func = s_main,
.visibility = ProcessVisibilityHidden,
// UUID: 4a71eb65-238d-4faa-b2a0-112aa910d7b4
.uuid = {0x4a, 0x71, 0xeb, 0x65, 0x23, 0x8d, 0x4f, 0xaa, 0xb2, 0xa0, 0x11, 0x2a, 0xa9, 0x10, 0xd7, 0xb4},
},
.name = "Battery Critical",
.run_level = ProcessAppRunLevelCritical,
};
return (const PebbleProcessMd*) &s_app_md;
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* battery_critical_get_app_info();

View file

@ -0,0 +1,170 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health.h"
#include "health_card_view.h"
#include "health_data.h"
#include "applib/app.h"
#include "applib/ui/dialogs/expandable_dialog.h"
#include "kernel/event_loop.h"
#include "kernel/pbl_malloc.h"
#include "kernel/ui/modals/modal_manager.h"
#include "popups/health_tracking_ui.h"
#include "process_state/app_state/app_state.h"
#include "services/normal/activity/activity.h"
#include "services/normal/activity/activity_private.h"
#include "services/normal/timeline/timeline.h"
#include "system/logging.h"
// Health app versions
// 0: Invalid (app was never opened)
// 1: Initial version
// 2: Graphs moved to mobile apps
// 3: 4.0 app redesign
#define CURRENT_HEALTH_APP_VERSION 3
////////////////////////////////////////////////////////////////////////////////////////////////////
// Main Structures
//
//! Main structure for application
typedef struct HealthAppData {
HealthCardView *health_card_view;
HealthData *health_data;
} HealthAppData;
////////////////////////////////////////////////////////////////////////////////////////////////////
// Callbacks
//
//! Tick timer service callback
//! @param tick_time Pointer to time structure
//! @param units_changed The time units changed
static void prv_tick_timer_handler(struct tm *tick_time, TimeUnits units_changed) {
HealthAppData *health_app_data = app_state_get_user_data();
health_data_update_step_derived_metrics(health_app_data->health_data);
health_card_view_mark_dirty(health_app_data->health_card_view);
}
// Activity change callback
static void prv_health_service_event_handler(HealthEventType event, void *context) {
HealthAppData *health_app_data = context;
if (event == HealthEventMovementUpdate) {
const uint32_t steps_today = health_service_sum_today(HealthMetricStepCount);
health_data_update_steps(health_app_data->health_data, steps_today);
} else if (event == HealthEventSleepUpdate) {
const uint32_t seconds_sleep_today = health_service_sum_today(HealthMetricSleepSeconds);
const uint32_t seconds_restful_sleep_today =
health_service_sum_today(HealthMetricSleepRestfulSeconds);
health_data_update_sleep(health_app_data->health_data, seconds_sleep_today,
seconds_restful_sleep_today);
} else if (event == HealthEventHeartRateUpdate) {
health_data_update_current_bpm(health_app_data->health_data);
} else {
health_data_update(health_app_data->health_data);
}
health_card_view_mark_dirty(health_app_data->health_card_view);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Initialization and Termination
//
//! Initialize application
static void prv_finish_initilization_cb(bool in_focus) {
if (in_focus) {
HealthAppData *health_app_data = app_state_get_user_data();
tick_timer_service_subscribe(MINUTE_UNIT, prv_tick_timer_handler);
health_service_set_heart_rate_sample_period(1 /* interval_s */);
// Subscribing to health events causes a `HealthEventSignificantUpdate` which
// will trigger us to update our health data
health_service_events_subscribe(prv_health_service_event_handler, health_app_data);
// Unsubscribe, we only want to do this on the initial appearance (opening the app)
app_focus_service_unsubscribe();
}
}
static void prv_initialize(void) {
if (!activity_prefs_tracking_is_enabled()) {
/// Health disabled text
static const char *msg = i18n_noop("Track your steps, sleep, and more!"
" Enable Pebble Health in the mobile app.");
health_tracking_ui_show_message(RESOURCE_ID_HEART_TINY, msg, true);
return;
}
activity_prefs_set_health_app_opened_version(CURRENT_HEALTH_APP_VERSION);
HealthAppData *health_app_data = app_zalloc_check(sizeof(HealthAppData));
app_state_set_user_data(health_app_data);
health_app_data->health_data = health_data_create();
health_data_update_quick(health_app_data->health_data);
health_app_data->health_card_view = health_card_view_create(health_app_data->health_data);
health_card_view_push(health_app_data->health_card_view);
// Finish up initializing the app a bit later. This helps reduce lag when opening the app
app_focus_service_subscribe_handlers((AppFocusHandlers){
.did_focus = prv_finish_initilization_cb,
});
}
//! Terminate application
static void prv_terminate(void) {
HealthAppData *health_app_data = app_state_get_user_data();
// cancel explicit hr sample period
health_service_set_heart_rate_sample_period(0 /* interval_s */);
if (health_app_data) {
health_card_view_destroy(health_app_data->health_card_view);
health_data_destroy(health_app_data->health_data);
app_free(health_app_data);
}
}
//! Main entry point
static void prv_main(void) {
prv_initialize();
app_event_loop();
prv_terminate();
}
const PebbleProcessMd *health_app_get_info(void) {
static const PebbleProcessMdSystem s_health_app_info = {
.common = {
.main_func = &prv_main,
.uuid = UUID_HEALTH_DATA_SOURCE,
#if CAPABILITY_HAS_CORE_NAVIGATION4
.visibility = ProcessVisibilityHidden,
#endif
},
.name = i18n_noop("Health"),
};
return (const PebbleProcessMd*) &s_health_app_info;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
//! Call for system to obtain information about the application
//! @return System information about the app
const PebbleProcessMd *health_app_get_info(void);

View file

@ -0,0 +1,156 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_activity_detail_card.h"
#include "health_detail_card.h"
#include "services/normal/activity/health_util.h"
#include "kernel/pbl_malloc.h"
#include "services/common/i18n/i18n.h"
#include <stdio.h>
typedef struct HealthActivityDetailCard {
int32_t daily_avg;
int32_t weekly_max;
int16_t num_headings;
HealthDetailHeading headings[MAX_NUM_HEADINGS];
int16_t num_subtitles;
HealthDetailSubtitle subtitles[MAX_NUM_SUBTITLES];
int16_t num_zones;
HealthDetailZone zones[MAX_NUM_ZONES];
} HealthActivityDetailCardData;
static void prv_set_calories(char *buffer, size_t buffer_size, int32_t current_calories) {
if (current_calories == 0) {
strncpy(buffer, EN_DASH, buffer_size);
return;
}
snprintf(buffer, buffer_size, "%"PRId32, current_calories);
}
static void prv_set_distance(char *buffer, size_t buffer_size, int32_t current_distance_meters) {
if (current_distance_meters == 0) {
strncpy(buffer, EN_DASH, buffer_size);
return;
}
const int conversion_factor = health_util_get_distance_factor();
const char *units_string = health_util_get_distance_string(i18n_noop("MI"), i18n_noop("KM"));
char distance_buffer[HEALTH_WHOLE_AND_DECIMAL_LENGTH];
health_util_format_whole_and_decimal(distance_buffer, HEALTH_WHOLE_AND_DECIMAL_LENGTH,
current_distance_meters, conversion_factor);
snprintf(buffer, buffer_size, "%s%s", distance_buffer, units_string);
}
static void prv_set_avg(char *buffer, size_t buffer_size, int32_t daily_avg, void *i18n_owner) {
int pos = 0;
pos += snprintf(buffer, buffer_size,
PBL_IF_ROUND_ELSE("%s\n", "%s"), i18n_get("30 DAY AVG", i18n_owner));
if (daily_avg > 0) {
snprintf(buffer + pos, buffer_size - pos, " %"PRId32, daily_avg);
} else {
snprintf(buffer + pos, buffer_size - pos, " "EN_DASH);
}
}
Window *health_activity_detail_card_create(HealthData *health_data) {
HealthActivityDetailCardData *card_data = app_zalloc_check(sizeof(HealthActivityDetailCardData));
card_data->daily_avg = health_data_steps_get_monthly_average(health_data);
const GColor fill_color = PBL_IF_COLOR_ELSE(GColorIslamicGreen, GColorDarkGray);
const GColor today_fill_color = PBL_IF_COLOR_ELSE(GColorScreaminGreen, GColorDarkGray);
health_detail_card_set_render_day_zones(card_data->zones,
&card_data->num_zones,
&card_data->weekly_max,
false /* format hours and minutes */,
true /* show crown */,
fill_color,
today_fill_color,
health_data_steps_get(health_data),
card_data);
const size_t buffer_len = 32;
HealthDetailHeading *heading = &card_data->headings[card_data->num_headings++];
*heading = (HealthDetailHeading) {
.primary_label = (char *)i18n_get("CALORIES", card_data),
.primary_value = app_zalloc_check(buffer_len),
.secondary_label = (char *)i18n_get("DISTANCE", card_data),
.secondary_value = app_zalloc_check(buffer_len),
.fill_color = GColorWhite,
.outline_color = PBL_IF_COLOR_ELSE(GColorClear, GColorBlack),
};
prv_set_calories(heading->primary_value, buffer_len,
health_data_current_calories_get(health_data));
prv_set_distance(heading->secondary_value, buffer_len,
health_data_current_distance_meters_get(health_data));
HealthDetailSubtitle *subtitle = &card_data->subtitles[card_data->num_subtitles++];
*subtitle = (HealthDetailSubtitle) {
.label = app_zalloc_check(buffer_len),
.fill_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack),
};
prv_set_avg(subtitle->label, buffer_len, card_data->daily_avg, card_data);
const HealthDetailCardConfig config = {
.num_headings = card_data->num_headings,
.headings = card_data->headings,
.num_subtitles = card_data->num_subtitles,
.subtitles = card_data->subtitles,
.daily_avg = card_data->daily_avg,
.weekly_max = card_data->weekly_max,
.bg_color = PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite),
.num_zones = card_data->num_zones,
.zones = card_data->zones,
.data = card_data,
};
return (Window *)health_detail_card_create(&config);
}
void health_activity_detail_card_destroy(Window *window) {
HealthDetailCard *card = (HealthDetailCard *)window;
HealthActivityDetailCardData *card_data = card->data;
for (int i = 0; i < card_data->num_headings; i++) {
app_free(card_data->headings[i].primary_value);
app_free(card_data->headings[i].secondary_value);
}
for (int i = 0; i < card_data->num_subtitles; i++) {
app_free(card_data->subtitles[i].label);
}
for (int i = 0; i < card_data->num_zones; i++) {
app_free(card_data->zones[i].label);
}
i18n_free_all(card_data);
app_free(card_data);
health_detail_card_destroy(card);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
//! Creates a health activity detail window
//! @param HealthData pointer to the health data to be given to this card
//! @return A pointer to a newly allocated health activity detail window
Window *health_activity_detail_card_create(HealthData *health_data);
//! Destroys a health activity detail window
//! @param window Window pointer to health activity detail window
void health_activity_detail_card_destroy(Window *window);

View file

@ -0,0 +1,198 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_activity_summary_card.h"
#include "health_activity_summary_card_segments.h"
#include "health_activity_detail_card.h"
#include "health_progress.h"
#include "health_ui.h"
#include "services/normal/activity/health_util.h"
#include "applib/pbl_std/pbl_std.h"
#include "applib/ui/kino/kino_reel.h"
#include "applib/ui/text_layer.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "system/logging.h"
#include "util/size.h"
#include "util/string.h"
typedef struct HealthActivitySummaryCardData {
HealthData *health_data;
HealthProgressBar progress_bar;
KinoReel *icon;
int32_t current_steps;
int32_t typical_steps;
int32_t daily_average_steps;
} HealthActivitySummaryCardData;
#define PROGRESS_CURRENT_COLOR (PBL_IF_COLOR_ELSE(GColorIslamicGreen, GColorDarkGray))
#define PROGRESS_TYPICAL_COLOR (PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack))
#define PROGRESS_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorDarkGray, GColorClear))
#define PROGRESS_OUTLINE_COLOR (PBL_IF_COLOR_ELSE(GColorClear, GColorBlack))
#define CURRENT_TEXT_COLOR PROGRESS_CURRENT_COLOR
#define CARD_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite))
static void prv_render_progress_bar(GContext *ctx, Layer *base_layer) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_BACKGROUND_COLOR,
0, HEALTH_PROGRESS_BAR_MAX_VALUE);
const int32_t progress_max = MAX(data->current_steps, data->daily_average_steps);
if (!progress_max) {
health_progress_bar_outline(ctx, &data->progress_bar, PROGRESS_OUTLINE_COLOR);
return;
}
const int current_fill = data->current_steps * HEALTH_PROGRESS_BAR_MAX_VALUE / progress_max;
const int typical_fill = data->typical_steps * HEALTH_PROGRESS_BAR_MAX_VALUE / progress_max;
#if PBL_COLOR
const bool behind_typical = (data->current_steps < data->typical_steps);
if (behind_typical) {
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, 0, typical_fill);
}
#endif
if (data->current_steps) {
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_CURRENT_COLOR, 0, current_fill);
}
#if PBL_COLOR
if (!behind_typical) {
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_fill);
}
#else
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_fill);
#endif
// This needs to be done after drawing the progress bars or else the progress fill
// overlaps the outline and things look weird
health_progress_bar_outline(ctx, &data->progress_bar, PROGRESS_OUTLINE_COLOR);
}
static void prv_render_icon(GContext *ctx, Layer *base_layer) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(43, 38), 43);
const int x_center_offset = PBL_IF_BW_ELSE(19, 18);
kino_reel_draw(data->icon, ctx, GPoint(base_layer->bounds.size.w / 2 - x_center_offset, y));
}
static void prv_render_current_steps(GContext *ctx, Layer *base_layer) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
char buffer[8];
GFont font;
if (data->current_steps) {
font = fonts_get_system_font(FONT_KEY_LECO_26_BOLD_NUMBERS_AM_PM);
snprintf(buffer, sizeof(buffer), "%"PRIu32"", data->current_steps);
} else {
font = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
snprintf(buffer, sizeof(buffer), EM_DASH);
}
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88);
graphics_context_set_text_color(ctx, CURRENT_TEXT_COLOR);
graphics_draw_text(ctx, buffer, font,
GRect(0, y, base_layer->bounds.size.w, 35),
GTextOverflowModeFill, GTextAlignmentCenter, NULL);
}
static void prv_render_typical_steps(GContext *ctx, Layer *base_layer) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
char steps_buffer[12];
if (data->typical_steps) {
snprintf(steps_buffer, sizeof(steps_buffer), "%"PRId32, data->typical_steps);
} else {
snprintf(steps_buffer, sizeof(steps_buffer), EM_DASH);
}
health_ui_render_typical_text_box(ctx, base_layer, steps_buffer);
}
static void prv_base_layer_update_proc(Layer *base_layer, GContext *ctx) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
data->current_steps = health_data_current_steps_get(data->health_data);
data->typical_steps = health_data_steps_get_current_average(data->health_data);
data->daily_average_steps = health_data_steps_get_cur_wday_average(data->health_data);
prv_render_icon(ctx, base_layer);
prv_render_progress_bar(ctx, base_layer);
prv_render_current_steps(ctx, base_layer);
prv_render_typical_steps(ctx, base_layer);
}
static void prv_activity_detail_card_unload_callback(Window *window) {
health_activity_detail_card_destroy(window);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
Layer *health_activity_summary_card_create(HealthData *health_data) {
// create base layer
Layer *base_layer = layer_create_with_data(GRectZero, sizeof(HealthActivitySummaryCardData));
HealthActivitySummaryCardData *health_activity_summary_card_data = layer_get_data(base_layer);
layer_set_update_proc(base_layer, prv_base_layer_update_proc);
// set health data
*health_activity_summary_card_data = (HealthActivitySummaryCardData) {
.health_data = health_data,
.icon = kino_reel_create_with_resource(RESOURCE_ID_HEALTH_APP_ACTIVITY),
.progress_bar = {
.num_segments = ARRAY_LENGTH(s_activity_summary_progress_segments),
.segments = s_activity_summary_progress_segments,
},
};
return base_layer;
}
void health_activity_summary_card_select_click_handler(Layer *layer) {
HealthActivitySummaryCardData *health_activity_summary_card_data = layer_get_data(layer);
HealthData *health_data = health_activity_summary_card_data->health_data;
Window *window = health_activity_detail_card_create(health_data);
window_set_window_handlers(window, &(WindowHandlers) {
.unload = prv_activity_detail_card_unload_callback,
});
app_window_stack_push(window, true);
}
void health_activity_summary_card_destroy(Layer *base_layer) {
HealthActivitySummaryCardData *data = layer_get_data(base_layer);
i18n_free_all(base_layer);
kino_reel_destroy(data->icon);
layer_destroy(base_layer);
}
GColor health_activity_summary_card_get_bg_color(Layer *layer) {
return CARD_BACKGROUND_COLOR;
}
bool health_activity_summary_show_select_indicator(Layer *layer) {
return true;
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
//! Creates a special layer with data
//! @param health_data A pointer to the health data being given this card
//! @return A pointer to a newly allocated layer, which contains its own data
Layer *health_activity_summary_card_create(HealthData *health_data);
//! Health activity summary select click handler
//! @param layer A pointer to an existing layer containing its own data
void health_activity_summary_card_select_click_handler(Layer *layer);
//! Destroy a special layer
//! @param base_layer A pointer to an existing layer containing its own data
void health_activity_summary_card_destroy(Layer *base_layer);
//! Health activity summary layer background color getter
GColor health_activity_summary_card_get_bg_color(Layer *layer);
//! Health activity summary layer select click is available
bool health_activity_summary_show_select_indicator(Layer *layer);

View file

@ -0,0 +1,159 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_progress.h"
//! 5 main segments + 2 real corners + 2 endcaps implemented as corners (for bw)
//! Each of the 5 non-corener segments get 20% of the total
#define AMOUNT_PER_SEGMENT (HEALTH_PROGRESS_BAR_MAX_VALUE * 20 / 100)
// Found through trial and error
#define DEFAULT_MARK_WIDTH 50
#if PBL_BW
// The shape of the hexagon is slightly different on BW than on Color
static HealthProgressSegment s_activity_summary_progress_segments[] = {
{
// This is an endcap for BW (is a no-op on color)
.type = HealthProgressSegmentType_Corner,
.points = {{42, 85}, {51, 85}, {42, 85}, {51, 85}},
},
{
// Left side bottom
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{42, 84}, {51, 84}, {38, 58}, {28, 58}},
},
{
// Left side top
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{38, 57}, {28, 57}, {46, 26}, {56, 26}},
},
{
// Top left corner
.type = HealthProgressSegmentType_Corner,
.points = {{56, 26}, {46, 26}, {50, 18}, {56, 18}},
},
{
// Center top
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH * 2,
.points = {{55, 26}, {88, 26}, {89, 18}, {54, 18}},
},
{
// Top right corner
.type = HealthProgressSegmentType_Corner,
.points = {{88, 26}, {88, 18}, {92, 18}, {96, 26}},
},
{
// Right side top
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{87, 26}, {96, 26}, {113, 57}, {104, 57}},
},
{
// Right side bottom
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{104, 58}, {113, 58}, {99, 84}, {90, 84}},
},
{
// This is an endcap for BW (is a no-op on color)
.type = HealthProgressSegmentType_Corner,
.points = {{99, 85}, {90, 85}, {99, 85}, {90, 85}},
},
};
#else // Color
// The shape is the same, but the offsets are different
// Slightly adjust the points on Round
#define X_ADJ (PBL_IF_ROUND_ELSE(18, 0))
#define Y_ADJ (PBL_IF_ROUND_ELSE(6, 0))
static HealthProgressSegment s_activity_summary_progress_segments[] = {
{
// This is an endcap for BW (is a no-op on color)
.type = HealthProgressSegmentType_Corner,
.points = {{46 + X_ADJ, 81 + Y_ADJ}, {58 + X_ADJ, 81 + Y_ADJ},
{46 + X_ADJ, 81 + Y_ADJ}, {58 + X_ADJ, 81 + Y_ADJ}},
},
{
// Left side bottom
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{46 + X_ADJ, 81 + Y_ADJ}, {58 + X_ADJ, 81 + Y_ADJ},
{41 + X_ADJ, 51 + Y_ADJ}, {29 + X_ADJ, 51 + Y_ADJ}},
},
{
// Left side top
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{29 + X_ADJ, 51 + Y_ADJ}, {41 + X_ADJ, 51 + Y_ADJ},
{57 + X_ADJ, 24 + Y_ADJ}, {45 + X_ADJ, 24 + Y_ADJ}},
},
{
// Top left corner
.type = HealthProgressSegmentType_Corner,
.points = {{57 + X_ADJ, 24 + Y_ADJ}, {45 + X_ADJ, 24 + Y_ADJ},
{51 + X_ADJ, 15 + Y_ADJ}, {57 + X_ADJ, 15 + Y_ADJ}},
},
{
// Center top
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH * 2,
.points = {{55 + X_ADJ, 24 + Y_ADJ}, {89 + X_ADJ, 24 + Y_ADJ},
{89 + X_ADJ, 15 + Y_ADJ}, {55 + X_ADJ, 15 + Y_ADJ}},
},
{
// Top right corner
.type = HealthProgressSegmentType_Corner,
.points = {{87 + X_ADJ, 24 + Y_ADJ}, {87 + X_ADJ, 15 + Y_ADJ},
{93 + X_ADJ, 15 + Y_ADJ}, {99 + X_ADJ, 24 + Y_ADJ}},
},
{
// Right side top
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{87 + X_ADJ, 24 + Y_ADJ}, {99 + X_ADJ, 24 + Y_ADJ},
{115 + X_ADJ, 51 + Y_ADJ}, {103 + X_ADJ, 51 + Y_ADJ}},
},
{
// Right side bottom
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{103 + X_ADJ, 51 + Y_ADJ}, {115 + X_ADJ, 51 + Y_ADJ},
{98 + X_ADJ, 81 + Y_ADJ}, {86 + X_ADJ, 81 + Y_ADJ}},
},
{
// This is an endcap for BW (is a no-op on color)
.type = HealthProgressSegmentType_Corner,
.points = {{98 + X_ADJ, 81 + Y_ADJ}, {86 + X_ADJ, 81 + Y_ADJ},
{98 + X_ADJ, 81 + Y_ADJ}, {86 + X_ADJ, 81 + Y_ADJ}},
},
};
#endif

View file

@ -0,0 +1,392 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_card_view.h"
#include "health.h"
#include "health_activity_summary_card.h"
#include "health_sleep_summary_card.h"
#include "health_hr_summary_card.h"
#include "applib/app_launch_reason.h"
#include "applib/ui/action_button.h"
#include "applib/ui/content_indicator.h"
#include "applib/ui/content_indicator_private.h"
#include "kernel/pbl_malloc.h"
#include "services/normal/activity/activity_private.h"
#include "services/normal/timeline/health_layout.h"
#include "system/logging.h"
#include "util/time/time.h"
#define SELECT_INDICATOR_COLOR (PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack))
#define BACK_TO_WATCHFACE (-1)
// Enum for different card types
typedef enum {
Card_ActivitySummary,
#if CAPABILITY_HAS_BUILTIN_HRM
Card_HrSummary,
#endif
Card_SleepSummary,
CardCount
} Card;
// Main structure for card view
typedef struct HealthCardView {
Window window;
HealthData *health_data;
Card current_card_index;
Layer *card_layers[CardCount];
Animation *slide_animation;
Layer select_indicator_layer;
Layer down_arrow_layer;
Layer up_arrow_layer;
ContentIndicator down_indicator;
ContentIndicator up_indicator;
} HealthCardView;
static Layer* (*s_card_view_create[CardCount])(HealthData *health_data) = {
[Card_ActivitySummary] = health_activity_summary_card_create,
#if CAPABILITY_HAS_BUILTIN_HRM
[Card_HrSummary] = health_hr_summary_card_create,
#endif
[Card_SleepSummary] = health_sleep_summary_card_create,
};
static void (*s_card_view_select_click_handler[CardCount])(Layer *layer) = {
[Card_ActivitySummary] = health_activity_summary_card_select_click_handler,
#if CAPABILITY_HAS_BUILTIN_HRM
[Card_HrSummary] = health_hr_summary_card_select_click_handler,
#endif
[Card_SleepSummary] = health_sleep_summary_card_select_click_handler,
};
static GColor (*s_card_view_get_bg_color[CardCount])(Layer *layer) = {
[Card_ActivitySummary] = health_activity_summary_card_get_bg_color,
#if CAPABILITY_HAS_BUILTIN_HRM
[Card_HrSummary] = health_hr_summary_card_get_bg_color,
#endif
[Card_SleepSummary] = health_sleep_summary_card_get_bg_color,
};
static bool (*s_card_view_show_select_indicator[CardCount])(Layer *layer) = {
[Card_ActivitySummary] = health_activity_summary_show_select_indicator,
#if CAPABILITY_HAS_BUILTIN_HRM
[Card_HrSummary] = health_hr_summary_show_select_indicator,
#endif
[Card_SleepSummary] = health_sleep_summary_show_select_indicator,
};
////////////////////////////////////////////////////////////////////////////////////////////////////
// Private Functions
//
static int prv_get_next_card_idx(Card current, bool up) {
const int direction = up ? 1 : -1;
int next = current + direction;
// Skip over the HR card if we don't support it
#if CAPABILITY_HAS_BUILTIN_HRM
if (next == Card_HrSummary && !activity_is_hrm_present()) {
next = next + direction;
}
// if heart rate is diabled, change the order of cards to Activiy <-> Sleep <-> HR
else if (activity_is_hrm_present() && !activity_prefs_heart_rate_is_enabled()) {
if (current == Card_ActivitySummary) {
next = up ? Card_SleepSummary : BACK_TO_WATCHFACE;
} else if (current == Card_SleepSummary) {
next = up ? Card_HrSummary : Card_ActivitySummary;
} else if (current == Card_HrSummary) {
next = up ? CardCount : Card_SleepSummary;
}
}
#endif
return next;
}
static void prv_select_indicator_layer_update_proc(Layer *layer, GContext *ctx) {
action_button_draw(ctx, layer, SELECT_INDICATOR_COLOR);
}
static void prv_refresh_select_indicator(HealthCardView *health_card_view) {
Layer *card_layer = health_card_view->card_layers[health_card_view->current_card_index];
const bool is_hidden = !s_card_view_show_select_indicator[health_card_view->current_card_index](
card_layer);
layer_set_hidden(&health_card_view->select_indicator_layer, is_hidden);
}
static void prv_content_indicator_setup_direction(HealthCardView *health_card_view,
ContentIndicator *content_indicator,
Layer *indicator_layer,
ContentIndicatorDirection direction) {
Layer *card_layer = health_card_view->card_layers[health_card_view->current_card_index];
GColor card_bg_color = s_card_view_get_bg_color[health_card_view->current_card_index](card_layer);
content_indicator_configure_direction(content_indicator, direction, &(ContentIndicatorConfig) {
.layer = indicator_layer,
.colors.foreground = gcolor_legible_over(card_bg_color),
.colors.background = card_bg_color,
});
}
static void prv_refresh_content_indicators(HealthCardView *health_card_view) {
prv_content_indicator_setup_direction(health_card_view,
&health_card_view->up_indicator,
&health_card_view->up_arrow_layer,
ContentIndicatorDirectionUp);
prv_content_indicator_setup_direction(health_card_view,
&health_card_view->down_indicator,
&health_card_view->down_arrow_layer,
ContentIndicatorDirectionDown);
bool is_up_visible = true;
if (prv_get_next_card_idx(health_card_view->current_card_index, true) >= CardCount) {
is_up_visible = false;
}
content_indicator_set_content_available(&health_card_view->up_indicator,
ContentIndicatorDirectionUp,
is_up_visible);
// Down is always visible (the watchface is always an option)
content_indicator_set_content_available(&health_card_view->down_indicator,
ContentIndicatorDirectionDown,
true);
}
static void prv_hide_content_indicators(HealthCardView *health_card_view) {
content_indicator_set_content_available(&health_card_view->up_indicator,
ContentIndicatorDirectionUp,
false);
content_indicator_set_content_available(&health_card_view->down_indicator,
ContentIndicatorDirectionDown,
false);
}
static void prv_set_window_background_color(HealthCardView *health_card_view) {
Layer *card_layer = health_card_view->card_layers[health_card_view->current_card_index];
window_set_background_color(&health_card_view->window,
s_card_view_get_bg_color[health_card_view->current_card_index](card_layer));
}
#define NUM_MID_FRAMES 1
static void prv_bg_animation_update(Animation *animation, AnimationProgress normalized) {
HealthCardView *health_card_view = animation_get_context(animation);
const AnimationProgress bounce_back_length =
(interpolate_moook_out_duration() * ANIMATION_NORMALIZED_MAX) /
interpolate_moook_soft_duration(NUM_MID_FRAMES);
if (normalized >= ANIMATION_NORMALIZED_MAX - bounce_back_length) {
prv_set_window_background_color(health_card_view);
}
}
static void prv_bg_animation_started_handler(Animation *animation, void *context) {
HealthCardView *health_card_view = context;
layer_set_hidden(health_card_view->card_layers[health_card_view->current_card_index], false);
layer_set_hidden(&health_card_view->select_indicator_layer, true);
prv_hide_content_indicators(health_card_view);
}
static void prv_bg_animation_stopped_handler(Animation *animation, bool finished, void *context) {
HealthCardView *health_card_view = context;
for (int i = 0; i < CardCount; i++) {
if (i != health_card_view->current_card_index) {
layer_set_hidden(health_card_view->card_layers[i], true);
}
}
if (!finished) {
prv_set_window_background_color(health_card_view);
} else {
prv_refresh_select_indicator(health_card_view);
prv_refresh_content_indicators(health_card_view);
}
}
static const AnimationImplementation prv_bg_animation_implementation = {
.update = &prv_bg_animation_update
};
static int64_t prv_interpolate_moook_soft(int32_t normalized, int64_t from, int64_t to) {
return interpolate_moook_soft(normalized, from, to, NUM_MID_FRAMES);
}
static Animation* prv_create_slide_animation(Layer *layer, GRect *from_frame, GRect *to_frame) {
Animation *anim = (Animation *)property_animation_create_layer_frame(layer, from_frame, to_frame);
animation_set_duration(anim, interpolate_moook_soft_duration(NUM_MID_FRAMES));
animation_set_custom_interpolation(anim, prv_interpolate_moook_soft);
return anim;
}
// Create animation
static void prv_schedule_slide_animation(HealthCardView *health_card_view,
Card next_card_index, bool slide_up) {
animation_unschedule(health_card_view->slide_animation);
health_card_view->slide_animation = NULL;
GRect window_bounds = window_get_root_layer(&health_card_view->window)->bounds;
Layer *current_card_layer = health_card_view->card_layers[health_card_view->current_card_index];
Layer *next_card_layer = health_card_view->card_layers[next_card_index];
GRect curr_stop = window_bounds;
curr_stop.origin.y = slide_up ? window_bounds.size.h : -window_bounds.size.h;
GRect next_start = window_bounds;
next_start.origin.y = slide_up ? -window_bounds.size.h : window_bounds.size.h;
Animation *curr_out = prv_create_slide_animation(current_card_layer, &window_bounds, &curr_stop);
Animation *next_in = prv_create_slide_animation(next_card_layer, &next_start, &window_bounds);
Animation *bg_anim = animation_create();
animation_set_duration(bg_anim, interpolate_moook_soft_duration(NUM_MID_FRAMES));
animation_set_handlers(bg_anim, (AnimationHandlers){
.started = prv_bg_animation_started_handler,
.stopped = prv_bg_animation_stopped_handler,
}, health_card_view);
animation_set_implementation(bg_anim, &prv_bg_animation_implementation);
health_card_view->slide_animation = animation_spawn_create(curr_out, next_in, bg_anim, NULL);
animation_schedule(health_card_view->slide_animation);
health_card_view->current_card_index = next_card_index;
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Callback Functions
//
// Up/Down click handler
static void prv_up_down_click_handler(ClickRecognizerRef recognizer, void *context) {
HealthCardView *health_card_view = context;
const bool slide_up = (click_recognizer_get_button_id(recognizer) == BUTTON_ID_UP);
const int next_card_index = prv_get_next_card_idx(health_card_view->current_card_index, slide_up);
if (next_card_index < 0) {
// Exit
app_window_stack_pop_all(true);
} else if (next_card_index >= CardCount) {
// Top of the list (no-op)
// TODO: maybe we want an animation?
return;
} else {
// animate the cards' positions
prv_schedule_slide_animation(health_card_view, next_card_index, slide_up);
}
}
// Select click handler
static void prv_select_click_handler(ClickRecognizerRef recognizer, void *context) {
HealthCardView *health_card_view = context;
Layer *layer = health_card_view->card_layers[health_card_view->current_card_index];
s_card_view_select_click_handler[health_card_view->current_card_index](layer);
health_card_view_mark_dirty(health_card_view);
}
// Click config provider
static void prv_click_config_provider(void *context) {
window_set_click_context(BUTTON_ID_UP, context);
window_set_click_context(BUTTON_ID_SELECT, context);
window_set_click_context(BUTTON_ID_DOWN, context);
window_single_click_subscribe(BUTTON_ID_UP, prv_up_down_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, prv_select_click_handler);
window_single_click_subscribe(BUTTON_ID_DOWN, prv_up_down_click_handler);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
HealthCardView *health_card_view_create(HealthData *health_data) {
HealthCardView *health_card_view = app_malloc_check(sizeof(HealthCardView));
*health_card_view = (HealthCardView) {
.health_data = health_data,
};
window_init(&health_card_view->window, "Health Card View");
window_set_click_config_provider_with_context(&health_card_view->window,
prv_click_config_provider, health_card_view);
Layer *window_root = window_get_root_layer(&health_card_view->window);
// create and add all card layers to window root layer
for (int i = 0; i < CardCount; i++) {
health_card_view->card_layers[i] = s_card_view_create[i](health_data);
layer_add_child(window_root, health_card_view->card_layers[i]);
}
// set starting card based on launch args
HealthLaunchArgs launch_args = { .args = app_launch_get_args() };
health_card_view->current_card_index =
(launch_args.card_type == HealthCardType_Sleep) ? Card_SleepSummary : Card_ActivitySummary;
// set window background
prv_set_window_background_color(health_card_view);
// position current card
layer_set_frame(health_card_view->card_layers[health_card_view->current_card_index],
&window_root->frame);
// setup select indicator
layer_init(&health_card_view->select_indicator_layer, &window_root->frame);
layer_add_child(window_root, &health_card_view->select_indicator_layer);
layer_set_update_proc(&health_card_view->select_indicator_layer,
prv_select_indicator_layer_update_proc);
// setup content indicators
const int content_indicator_height = PBL_IF_ROUND_ELSE(18, 11);
const GRect down_arrow_layer_frame = grect_inset(window_root->frame,
GEdgeInsets(window_root->frame.size.h - content_indicator_height, 0, 0));
layer_init(&health_card_view->down_arrow_layer, &down_arrow_layer_frame);
layer_add_child(window_root, &health_card_view->down_arrow_layer);
content_indicator_init(&health_card_view->down_indicator);
const GRect up_arrow_layer_frame = grect_inset(window_root->frame,
GEdgeInsets(0, 0, window_root->frame.size.h - content_indicator_height));
layer_init(&health_card_view->up_arrow_layer, &up_arrow_layer_frame);
layer_add_child(window_root, &health_card_view->up_arrow_layer);
content_indicator_init(&health_card_view->up_indicator);
prv_refresh_content_indicators(health_card_view);
return health_card_view;
}
void health_card_view_destroy(HealthCardView *health_card_view) {
// destroy cards
health_activity_summary_card_destroy(health_card_view->card_layers[Card_ActivitySummary]);
health_sleep_summary_card_destroy(health_card_view->card_layers[Card_SleepSummary]);
// destroy self
window_deinit(&health_card_view->window);
app_free(health_card_view);
}
void health_card_view_push(HealthCardView *health_card_view) {
app_window_stack_push(&health_card_view->window, true);
}
void health_card_view_mark_dirty(HealthCardView *health_card_view) {
layer_mark_dirty(health_card_view->card_layers[health_card_view->current_card_index]);
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
//! Main structure for card view
typedef struct HealthCardView HealthCardView;
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
//! Creates a HealthCardView
//! @param health_data A pointer to the health data being given this view
//! @return A pointer to the newly allocated HealthCardView
HealthCardView *health_card_view_create(HealthData *health_data);
//! Destroy a HealthCardView
//! @param health_card_view A pointer to an existing HealthCardView
void health_card_view_destroy(HealthCardView *health_card_view);
//! Push a HealthCardView to the window stack
//! @param health_card_view A pointer to an existing HealthCardView
void health_card_view_push(HealthCardView *health_card_view);
//! Mark the card view as dirty so it is refreshed
//! @param health_card_view A pointer to an existing HealthCardView
void health_card_view_mark_dirty(HealthCardView *health_card_view);

View file

@ -0,0 +1,324 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_data.h"
#include "health_data_private.h"
#include "applib/app_logging.h"
#include "applib/health_service_private.h"
#include "drivers/rtc.h"
#include "kernel/pbl_malloc.h"
#include "syscall/syscall.h"
#include "system/logging.h"
#include "util/math.h"
#include "util/stats.h"
#include "util/time/time.h"
T_STATIC void prv_merge_adjacent_sessions(ActivitySession *current,
ActivitySession *previous) {
if (previous == NULL || current == NULL) {
return;
}
if (current->type != previous->type ||
(current->type != ActivitySessionType_RestfulNap &&
current->type != ActivitySessionType_RestfulSleep)) {
// We only merge sessions if they are "deep" sleep/nap
return;
}
// [FBO]: note that this only works because sleep sessions are
// all we care about and they are sorted. Don't try to extend this to walk
// or run sessions
const uint16_t max_apart_merge_secs = 5 * SECONDS_PER_MINUTE;
time_t end_time = previous->start_utc + previous->length_min * SECONDS_PER_MINUTE;
if ((end_time + max_apart_merge_secs) > current->start_utc) {
current->length_min += previous->length_min +
(current->start_utc - end_time) / SECONDS_PER_MINUTE;
current->start_utc = previous->start_utc;
previous->length_min = 0;
previous->type = ActivitySessionType_None;
}
}
static void prv_mitsuta_mean_loop_itr(int64_t new_value, int64_t *sum, int64_t *D) {
int64_t delta = new_value - *D;
if (delta < SECONDS_PER_DAY * -1 / 2) {
*D = *D + delta + SECONDS_PER_DAY;
} else if (delta < SECONDS_PER_DAY / 2) {
*D = *D + delta;
} else {
*D = *D + delta - SECONDS_PER_DAY;
}
*sum = *sum + *D;
}
// API Functions
////////////////////////////////////////////////////////////////////////////////////////////////////
HealthData *health_data_create(void) {
return (HealthData *)app_zalloc_check(sizeof(HealthData));
}
void health_data_destroy(HealthData *health_data) {
app_free(health_data);
}
void health_data_update_quick(HealthData *health_data) {
const time_t now = rtc_get_time();
struct tm local_tm;
localtime_r(&now, &local_tm);
// Get the current steps
health_service_private_get_metric_history(HealthMetricStepCount, 1, health_data->step_data);
// Get the typical step averages for every 15 minutes
activity_get_step_averages(local_tm.tm_wday, &health_data->step_averages);
health_data->current_hr_bpm = health_service_peek_current_value(HealthMetricHeartRateBPM);
// Get the most recent stable HR Reading timestamp.
activity_get_metric(ActivityMetricHeartRateFilteredUpdatedTimeUTC, 1,
(int32_t *)&health_data->hr_last_updated);
}
void health_data_update(HealthData *health_data) {
const time_t now = rtc_get_time();
struct tm local_tm;
localtime_r(&now, &local_tm);
//! Step / activity related data
// Get the step totals for today and the past 6 days
health_service_private_get_metric_history(HealthMetricStepCount, DAYS_PER_WEEK,
health_data->step_data);
// Update distance / calories now that we have our steps
health_data_update_step_derived_metrics(health_data);
// Get the step averages for each 15 minute window. Used for typical steps
activity_get_step_averages(local_tm.tm_wday, &health_data->step_averages);
// Get the average steps for the past month
activity_get_metric_monthly_avg(ActivityMetricStepCount, &health_data->monthly_step_average);
//! Sleep related data
health_service_private_get_metric_history(HealthMetricSleepSeconds, DAYS_PER_WEEK,
health_data->sleep_data);
activity_get_metric_typical(ActivityMetricSleepTotalSeconds, local_tm.tm_wday,
&health_data->typical_sleep);
activity_get_metric(ActivityMetricSleepRestfulSeconds, 1, &health_data->deep_sleep);
activity_get_metric(ActivityMetricSleepEnterAtSeconds, 1, &health_data->sleep_start);
activity_get_metric(ActivityMetricSleepExitAtSeconds, 1, &health_data->sleep_end);
activity_get_metric_typical(ActivityMetricSleepEnterAtSeconds, local_tm.tm_wday,
&health_data->typical_sleep_start);
activity_get_metric_typical(ActivityMetricSleepExitAtSeconds, local_tm.tm_wday,
&health_data->typical_sleep_end);
activity_get_metric_monthly_avg(ActivityMetricSleepTotalSeconds,
&health_data->monthly_sleep_average);
//! Activity sessions
health_data->num_activity_sessions = ACTIVITY_MAX_ACTIVITY_SESSIONS_COUNT;
if (!activity_get_sessions(&health_data->num_activity_sessions,
health_data->activity_sessions)) {
PBL_LOG(LOG_LEVEL_ERROR, "Fetching activity sessions failed");
} else {
ActivitySession *previous_session = NULL;
for (unsigned int i = 0; i < health_data->num_activity_sessions; i++) {
ActivitySession *session = &health_data->activity_sessions[i];
prv_merge_adjacent_sessions(session, previous_session);
previous_session = session;
}
}
//! HR related data
health_data_update_current_bpm(health_data);
health_data_update_hr_zone_minutes(health_data);
}
void health_data_update_step_derived_metrics(HealthData *health_data) {
// get distance in meters
health_data->current_distance_meters = health_service_sum_today(HealthMetricWalkedDistanceMeters);
// get calories
health_data->current_calories = health_service_sum_today(HealthMetricActiveKCalories)
+ health_service_sum_today(HealthMetricRestingKCalories);
}
void health_data_update_steps(HealthData *health_data, uint32_t new_steps) {
health_data->step_data[0] = new_steps;
health_data_update_step_derived_metrics(health_data);
}
void health_data_update_sleep(HealthData *health_data, uint32_t new_sleep,
uint32_t new_deep_sleep) {
health_data->sleep_data[0] = new_sleep;
health_data->deep_sleep = new_deep_sleep;
}
void health_data_update_current_bpm(HealthData *health_data) {
health_data->resting_hr_bpm = activity_prefs_heart_get_resting_hr();
// Check the quality. If it doesn't meet our standards, bail
int32_t quality;
activity_get_metric(ActivityMetricHeartRateRawQuality, 1, &quality);
if (quality < HRMQuality_Acceptable) {
return;
}
uint32_t current_hr_timestamp;
activity_get_metric(ActivityMetricHeartRateRawUpdatedTimeUTC, 1,
(int32_t *)&current_hr_timestamp);
if (current_hr_timestamp > (uint32_t)health_data->hr_last_updated) {
health_data->current_hr_bpm = health_service_peek_current_value(HealthMetricHeartRateRawBPM);
health_data->hr_last_updated = current_hr_timestamp;
}
}
void health_data_update_hr_zone_minutes(HealthData *health_data) {
activity_get_metric(ActivityMetricHeartRateZone1Minutes, 1, &health_data->hr_zone1_minutes);
activity_get_metric(ActivityMetricHeartRateZone2Minutes, 1, &health_data->hr_zone2_minutes);
activity_get_metric(ActivityMetricHeartRateZone3Minutes, 1, &health_data->hr_zone3_minutes);
}
int32_t *health_data_steps_get(HealthData *health_data) {
return health_data->step_data;
}
int32_t health_data_current_steps_get(HealthData *health_data) {
return health_data->step_data[0];
}
int32_t health_data_current_distance_meters_get(HealthData *health_data) {
return health_data->current_distance_meters;
}
int32_t health_data_current_calories_get(HealthData *health_data) {
return health_data->current_calories;
}
static int32_t prv_health_data_get_n_average_chunks(HealthData *health_data, int number_of_chunks) {
uint32_t total_steps_avg = 0;
for (int i = 0; (i < ACTIVITY_NUM_METRIC_AVERAGES) && (i < number_of_chunks); i++) {
if (health_data->step_averages.average[i] != ACTIVITY_METRIC_AVERAGES_UNKNOWN) {
total_steps_avg += health_data->step_averages.average[i];
}
}
return total_steps_avg;
}
int32_t health_data_steps_get_current_average(HealthData *health_data) {
// get the current minutes into today
time_t utc_sec = rtc_get_time();
struct tm local_tm;
localtime_r(&utc_sec, &local_tm);
int32_t today_min = local_tm.tm_hour * MINUTES_PER_HOUR + local_tm.tm_min;
const int k_minutes_per_step_avg = MINUTES_PER_DAY / ACTIVITY_NUM_METRIC_AVERAGES;
// each average chunk is 15 mins long
if (health_data->step_average_last_updated_time !=
((today_min / k_minutes_per_step_avg) * k_minutes_per_step_avg)) {
// current_step_average is stale
health_data->current_step_average =
prv_health_data_get_n_average_chunks(health_data, today_min / k_minutes_per_step_avg);
health_data->step_average_last_updated_time =
(today_min / k_minutes_per_step_avg) * k_minutes_per_step_avg;
}
return health_data->current_step_average;
}
int32_t health_data_steps_get_cur_wday_average(HealthData *health_data) {
return prv_health_data_get_n_average_chunks(health_data, ACTIVITY_NUM_METRIC_AVERAGES);
}
int32_t health_data_steps_get_monthly_average(HealthData *health_data) {
return health_data->monthly_step_average;
}
int32_t *health_data_sleep_get(HealthData *health_data) {
return health_data->sleep_data;
}
int32_t health_data_current_sleep_get(HealthData *health_data) {
return health_data->sleep_data[0];
}
int32_t health_data_sleep_get_cur_wday_average(HealthData *health_data) {
return health_data->typical_sleep;
}
int32_t health_data_current_deep_sleep_get(HealthData *health_data) {
return health_data->deep_sleep;
}
int32_t health_data_sleep_get_monthly_average(HealthData *health_data) {
return health_data->monthly_sleep_average;
}
int32_t health_data_sleep_get_start_time(HealthData *health_data) {
return health_data->sleep_start;
}
int32_t health_data_sleep_get_end_time(HealthData *health_data) {
return health_data->sleep_end;
}
int32_t health_data_sleep_get_typical_start_time(HealthData *health_data) {
return health_data->typical_sleep_start;
}
int32_t health_data_sleep_get_typical_end_time(HealthData *health_data) {
return health_data->typical_sleep_end;
}
int32_t health_data_sleep_get_num_sessions(HealthData *health_data) {
return health_data->num_activity_sessions;
}
ActivitySession *health_data_sleep_get_sessions(HealthData *health_data) {
return health_data->activity_sessions;
}
uint32_t health_data_hr_get_current_bpm(HealthData *health_data) {
return health_data->current_hr_bpm;
}
uint32_t health_data_hr_get_resting_bpm(HealthData *health_data) {
return health_data->resting_hr_bpm;
}
time_t health_data_hr_get_last_updated_timestamp(HealthData *health_data) {
return health_data->hr_last_updated;
}
int32_t health_data_hr_get_zone1_minutes(HealthData *health_data) {
return health_data->hr_zone1_minutes;
}
int32_t health_data_hr_get_zone2_minutes(HealthData *health_data) {
return health_data->hr_zone2_minutes;
}
int32_t health_data_hr_get_zone3_minutes(HealthData *health_data) {
return health_data->hr_zone3_minutes;
}

View file

@ -0,0 +1,173 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "services/normal/activity/activity.h"
typedef struct {
int32_t sum;
int32_t avg;
int32_t max;
} BasicHealthStats;
typedef struct {
BasicHealthStats weekday;
BasicHealthStats weekend;
BasicHealthStats daily;
} WeeklyStats;
//! Health data model
typedef struct HealthData HealthData;
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
//! Create a health data structure
//! @return A pointer to the new HealthData structure
HealthData *health_data_create(void);
//! Destroy a health data structure
//! @param health_data A pointer to an existing health data structure
void health_data_destroy(HealthData *health_data);
//! Fetch the current activity data from the system
//! @param health_data A pointer to the health data to use
void health_data_update(HealthData *health_data);
//! Fetch only the data required to display the initial card.
//! This helps reduce lag when opening the app
//! @param health_data A pointer to the health data to use
void health_data_update_quick(HealthData *health_data);
//! Fetch the current data for step derived metrics (distance, active time, calories)
//! @param health_data A pointer to the health data to use
void health_data_update_step_derived_metrics(HealthData *health_data);
//! Update the number of steps the user has taken today
//! @param health_data A pointer to the health data to use
//! @param new_steps the new value of the steps for toaday
void health_data_update_steps(HealthData *health_data, uint32_t new_steps);
//! Update the number of seconds the user has slept today
//! @param health_data A pointer to the health data to use
//! @param new_sleep the new value of the seconds of sleep today
//! @param new_deep_sleep the new value of the seconds of deep sleep today
void health_data_update_sleep(HealthData *health_data, uint32_t new_sleep, uint32_t new_deep_sleep);
//! Update the current HR BPM
//! @param health_data A pointer to the health data to use
void health_data_update_current_bpm(HealthData *health_data);
//! Update the time in HR zones
//! @param health_data A pointer to the health data to use
void health_data_update_hr_zone_minutes(HealthData *health_data);
//! Get the current step count
//! @param health_data A pointer to the health data to use
//! @return the current step count
int32_t health_data_current_steps_get(HealthData *health_data);
//! Get the historical step data
//! @param health_data A pointer to the health data to use
//! @return A pointer to historical step data
int32_t *health_data_steps_get(HealthData *health_data);
//! Get the current distance traveled in meters
//! @param health_data A pointer to the health data to use
//! @return the current distance travelled in meters
int32_t health_data_current_distance_meters_get(HealthData *health_data);
//! Get the current calories
//! @param health_data A pointer to the health data to use
//! @return the current calories
int32_t health_data_current_calories_get(HealthData *health_data);
//! Get current number of steps that should be taken by this time today
//! @param health_data A pointer to the health data to use
//! @return The integer number of steps that should be taken by this time today
int32_t health_data_steps_get_current_average(HealthData *health_data);
//! Get the step average for the current day of the week
//! @param health_data A pointer to the health data to use
//! @return An integer value for the number of steps that are typically taken on this week day
int32_t health_data_steps_get_cur_wday_average(HealthData *health_data);
//! Get the step average over the past month
//! @param health_data A pointer to the health data to use
//! @return An integer value for the average number of steps that we taken over the past month
int32_t health_data_steps_get_monthly_average(HealthData *health_data);
//! Get the historical sleep data
//! @param health_data A pointer to the health data to use
//! @return A pointer to historical sleep data
int32_t *health_data_sleep_get(HealthData *health_data);
//! Get the current sleep length
//! @param health_data A pointer to the health data to use
//! @return the current sleep length
int32_t health_data_current_sleep_get(HealthData *health_data);
//! Gets the typical sleep duration for the current weekday
int32_t health_data_sleep_get_cur_wday_average(HealthData *health_data);
//! Get the current deep sleep data
//! @param health_data A pointer to the health data to use
//! @return the current deep sleep length
int32_t health_data_current_deep_sleep_get(HealthData *health_data);
//! Get the sleep average over the past month
//! @param health_data A pointer to the health data to use
//! @return The average daily sleep over the past month
int32_t health_data_sleep_get_monthly_average(HealthData *health_data);
// Get the sleep start time
int32_t health_data_sleep_get_start_time(HealthData *health_data);
// Get the sleep end time
int32_t health_data_sleep_get_end_time(HealthData *health_data);
// Get the typical sleep start time
int32_t health_data_sleep_get_typical_start_time(HealthData *health_data);
// Get the typical sleep end time
int32_t health_data_sleep_get_typical_end_time(HealthData *health_data);
// Get the number of sleep sessions
int32_t health_data_sleep_get_num_sessions(HealthData *health_data);
// Get today's sleep sessions
ActivitySession *health_data_sleep_get_sessions(HealthData *health_data);
// Get current BPM
uint32_t health_data_hr_get_current_bpm(HealthData *health_data);
// Get resting BPM
uint32_t health_data_hr_get_resting_bpm(HealthData *health_data);
// Get HR last updated timestamp
time_t health_data_hr_get_last_updated_timestamp(HealthData *health_data);
// Get number of minutes in Zone 1
int32_t health_data_hr_get_zone1_minutes(HealthData *health_data);
// Get number of minutes in Zone 2
int32_t health_data_hr_get_zone2_minutes(HealthData *health_data);
// Get number of minutes in Zone 3
int32_t health_data_hr_get_zone3_minutes(HealthData *health_data);

View file

@ -0,0 +1,55 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
typedef struct HealthData {
//!< Current step / activity info
int32_t step_data[DAYS_PER_WEEK]; //!< Step histroy for today and the previous 6 days
int32_t current_distance_meters;
int32_t current_calories;
//!< Typical step info
ActivityMetricAverages step_averages; //!< The step averages for the current day
int32_t current_step_average; //!< The current step average so far
int32_t step_average_last_updated_time; //!< The time at which current_step_average was updated
int32_t monthly_step_average;
int32_t sleep_data[DAYS_PER_WEEK]; //!< Sleep history for the past week
int32_t typical_sleep; //! Typical sleep for the current week day
int32_t deep_sleep; //!< Amount of deep sleep last night
int32_t sleep_start; //!< When the user went to sleep (seconds after midnight)
int32_t sleep_end; //!< When the user woke up (seconds after midnight)
int32_t typical_sleep_start; //!< When the user typically goes to sleep
int32_t typical_sleep_end; //!< When the user typically wakes up
int32_t monthly_sleep_average;
uint32_t num_activity_sessions; //!< Number of activity sessions returned by the API
ActivitySession activity_sessions[ACTIVITY_MAX_ACTIVITY_SESSIONS_COUNT]; //!< Activity sessions
int32_t current_hr_bpm; //!< Current BPM
int32_t resting_hr_bpm; //!< Resting BPM
time_t hr_last_updated; //!< Time at which HR data was last updated
int32_t hr_zone1_minutes;
int32_t hr_zone2_minutes;
int32_t hr_zone3_minutes;
} HealthData;

View file

@ -0,0 +1,546 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_detail_card.h"
#include "applib/pbl_std/pbl_std.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/health_util.h"
#include "util/size.h"
#include "system/logging.h"
#define CORNER_RADIUS (3)
static void prv_draw_headings(HealthDetailCard *detail_card, GContext *ctx, const Layer *layer) {
const int16_t rect_padding = PBL_IF_RECT_ELSE(5, 22);
const int16_t rect_height = 35;
for (int i = 0; i < detail_card->num_headings; i++) {
HealthDetailHeading *heading = &detail_card->headings[i];
if (!heading->primary_label) {
continue;
}
#if PBL_ROUND
int16_t header_y_origin = ((detail_card->num_headings > 1) ? 22 : 32) + (i * (rect_height + 5));
#endif
GRect header_rect = grect_inset(layer->bounds, GEdgeInsets(rect_padding));
header_rect.origin.y += PBL_IF_RECT_ELSE(detail_card->y_origin, header_y_origin);
header_rect.size.h = rect_height;
detail_card->y_origin += rect_height + rect_padding;
#if PBL_BW
const GRect inner_rect = grect_inset(header_rect, GEdgeInsets(1));
const uint16_t inner_corner_radius = CORNER_RADIUS - 1;
graphics_context_set_stroke_color(ctx, heading->outline_color);
graphics_draw_round_rect(ctx, &inner_rect, inner_corner_radius);
graphics_draw_round_rect(ctx, &header_rect, CORNER_RADIUS);
#else
graphics_context_set_fill_color(ctx, heading->fill_color);
graphics_fill_round_rect(ctx, &header_rect, CORNER_RADIUS, GCornersAll);
#endif
const bool has_secondary_heading = heading->secondary_label;
GRect label_rect = header_rect;
if (has_secondary_heading) {
label_rect.size.w /= 2;
}
label_rect.size.h = 12; // Restrict to a single line
graphics_context_set_text_color(ctx, gcolor_legible_over(heading->fill_color));
graphics_draw_text(ctx, heading->primary_label, detail_card->heading_label_font,
label_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
const int16_t value_rect_y_padding = 12;
GRect value_rect = label_rect;
value_rect.origin.y += value_rect_y_padding;
graphics_draw_text(ctx, heading->primary_value, detail_card->heading_value_font,
value_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
if (!heading->secondary_label) {
continue;
}
const int separator_padding = 5;
GPoint separator_top_point = GPoint(header_rect.origin.x + (header_rect.size.w / 2) - 1,
header_rect.origin.y + separator_padding);
GPoint separator_bot_point = GPoint(separator_top_point.x, separator_top_point.y +
header_rect.size.h - (separator_padding * 2) - 1);
graphics_draw_line(ctx, separator_top_point, separator_bot_point);
// draw another line to make the width 2px
separator_top_point.x++;
separator_bot_point.x++;
graphics_draw_line(ctx, separator_top_point, separator_bot_point);
label_rect.origin.x += label_rect.size.w;
value_rect.origin.x += value_rect.size.w;
graphics_draw_text(ctx, heading->secondary_label, detail_card->heading_label_font,
label_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
graphics_draw_text(ctx, heading->secondary_value, detail_card->heading_value_font,
value_rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static void prv_draw_subtitles(HealthDetailCard *detail_card, GContext *ctx, const Layer *layer) {
const int16_t rect_padding = PBL_IF_RECT_ELSE(5, 0);
const int16_t rect_height = PBL_IF_RECT_ELSE(23, 36);
for (int i = 0; i < detail_card->num_subtitles; i++) {
HealthDetailSubtitle *subtitle = &detail_card->subtitles[i];
if (!subtitle->label) {
continue;
}
GRect subtitle_rect = grect_inset(layer->bounds, GEdgeInsets(rect_padding));
subtitle_rect.origin.y += PBL_IF_RECT_ELSE(detail_card->y_origin, 125);
subtitle_rect.size.h = rect_height;
detail_card->y_origin += rect_height + rect_padding;
graphics_context_set_fill_color(ctx, subtitle->fill_color);
graphics_fill_round_rect(ctx, &subtitle_rect, CORNER_RADIUS, GCornersAll);
if (!gcolor_equal(subtitle->outline_color, GColorClear)) {
graphics_context_set_stroke_color(ctx, subtitle->outline_color);
graphics_draw_round_rect(ctx, &subtitle_rect, CORNER_RADIUS);
}
// font offset
subtitle_rect.origin.y -= PBL_IF_RECT_ELSE(1, 3);
graphics_context_set_text_color(ctx, gcolor_legible_over(subtitle->fill_color));
graphics_draw_text(ctx, subtitle->label, detail_card->subtitle_font, subtitle_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static void prv_draw_progress_bar(GContext *ctx, HealthProgressBar *progress_bar, GColor bg_color,
GColor fill_color, int current_progress, int typical_progress, int max_progress,
bool hide_typical) {
const GColor typical_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack);
const GColor outline_color = PBL_IF_COLOR_ELSE(GColorClear, GColorBlack);
health_progress_bar_fill(ctx, progress_bar, bg_color, 0, HEALTH_PROGRESS_BAR_MAX_VALUE);
if (max_progress > 0) {
const int current_fill = (current_progress * HEALTH_PROGRESS_BAR_MAX_VALUE) / max_progress;
health_progress_bar_fill(ctx, progress_bar, fill_color, 0, current_fill);
if (typical_progress > 0) {
const int typical_fill = (typical_progress * HEALTH_PROGRESS_BAR_MAX_VALUE) / max_progress;
health_progress_bar_mark(ctx, progress_bar, typical_color, typical_fill);
}
}
health_progress_bar_outline(ctx, progress_bar, outline_color);
}
#if PBL_RECT
static void prv_draw_progress_bar_in_zone(GContext *ctx, const GRect *zone_rect, GColor fill_color,
int current_progress, int typical_progress, int max_progress, bool hide_typical) {
const int16_t progress_bar_x = zone_rect->origin.x + PBL_IF_BW_ELSE(0, -1);
const int16_t progress_bar_y = zone_rect->origin.y + 22;
const int16_t progress_bar_width = zone_rect->size.w + PBL_IF_BW_ELSE(-2, 1);
const int16_t progress_bar_height = 10 + PBL_IF_BW_ELSE(-1, 0);
HealthProgressSegment segments[] = {
{
// Left side vertical line (needed for the draw outline function to draw the verticle lines)
.type = HealthProgressSegmentType_Corner,
.points = {
{progress_bar_x, progress_bar_y},
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x, progress_bar_y},
},
},
{
// Right side vertical line (needed for the draw outline function to draw the verticle lines)
.type = HealthProgressSegmentType_Corner,
.points = {
{progress_bar_x + progress_bar_width, progress_bar_y},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y},
},
},
{
// Horizontal bar from left line to right line
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = HEALTH_PROGRESS_BAR_MAX_VALUE,
.mark_width = 124, // Arbitrarily chosen through trial and error
.points = {
{progress_bar_x, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y + progress_bar_height},
{progress_bar_x + progress_bar_width, progress_bar_y},
{progress_bar_x, progress_bar_y},
},
},
};
HealthProgressBar progress_bar = {
.num_segments = ARRAY_LENGTH(segments),
.segments = segments,
};
const GColor bg_color = PBL_IF_COLOR_ELSE(GColorDarkGray, GColorWhite);
prv_draw_progress_bar(ctx, &progress_bar, bg_color, fill_color, current_progress,
typical_progress, max_progress, hide_typical);
}
static void prv_draw_zones(HealthDetailCard *detail_card, GContext *ctx) {
if (detail_card->num_zones <= 0) {
return;
}
const int16_t rect_padding = 5;
const int16_t rect_height = 33;
GRect zone_rect = grect_inset(detail_card->window.layer.bounds, GEdgeInsets(rect_padding));
zone_rect.origin.y += detail_card->y_origin;
zone_rect.size.h = rect_height;
for (int i = 0; i < detail_card->num_zones; i++) {
HealthDetailZone *zone = &detail_card->zones[i];
graphics_context_set_text_color(ctx, PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
graphics_draw_text(ctx, zone->label, detail_card->subtitle_font, zone_rect,
GTextOverflowModeWordWrap, GTextAlignmentLeft, NULL);
if (zone->show_crown) {
const GSize label_size = app_graphics_text_layout_get_content_size(zone->label,
detail_card->subtitle_font, zone_rect, GTextOverflowModeWordWrap, GTextAlignmentLeft);
GPoint icon_offset = zone_rect.origin;
icon_offset.x += label_size.w + 4;
#if PBL_BW
icon_offset.y += 2;
#endif
gdraw_command_image_draw(ctx, detail_card->icon_crown, icon_offset);
}
prv_draw_progress_bar_in_zone(ctx, &zone_rect, zone->fill_color, zone->progress,
detail_card->daily_avg, detail_card->max_progress, zone->hide_typical);
zone_rect.origin.y += rect_height + rect_padding;
detail_card->y_origin += rect_height + rect_padding;
}
detail_card->y_origin += rect_padding;
}
#endif // PBL_RECT
#if PBL_ROUND
static uint16_t prv_get_num_rows_callback(MenuLayer *menu_layer,
uint16_t section_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
return detail_card->num_zones + 1;
}
static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
MenuIndex selected_index = menu_layer_get_selected_index(&detail_card->menu_layer);
if (cell_index->row == 0) {
graphics_context_set_fill_color(ctx, detail_card->bg_color);
graphics_fill_rect(ctx, &cell_layer->bounds);
prv_draw_headings(detail_card, ctx, cell_layer);
prv_draw_subtitles(detail_card, ctx, cell_layer);
return;
}
HealthDetailZone *zone = &detail_card->zones[cell_index->row - 1];
const int16_t rect_padding = 5;
GRect label_rect = grect_inset(cell_layer->bounds, GEdgeInsets(rect_padding));
if (!menu_layer_is_index_selected(&detail_card->menu_layer, cell_index)) {
label_rect.origin.y = (cell_index->row < selected_index.row) ? 3 : 22;
graphics_context_set_text_color(ctx, GColorWhite);
graphics_draw_text(ctx, zone->label, detail_card->subtitle_font, label_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
} else {
const GRect cell_bounds = grect_inset(cell_layer->bounds, GEdgeInsets(0, -1));
HealthProgressSegment segments[] = {
{
// Horizontal bar from left line to right line
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = HEALTH_PROGRESS_BAR_MAX_VALUE,
.mark_width = 100, // Arbitrarily chosen through trial and error
.points = {
{cell_bounds.origin.x, cell_bounds.size.h},
{cell_bounds.size.w, cell_bounds.size.h},
{cell_bounds.size.w, cell_bounds.origin.y},
{cell_bounds.origin.x, cell_bounds.origin.y},
},
},
};
HealthProgressBar progress_bar = {
.num_segments = ARRAY_LENGTH(segments),
.segments = segments,
};
prv_draw_progress_bar(ctx, &progress_bar, GColorLightGray, zone->fill_color, zone->progress,
detail_card->daily_avg, detail_card->max_progress, zone->hide_typical);
label_rect.origin.y += 3;
if (zone->show_crown) {
const GSize icon_size = gdraw_command_image_get_bounds_size(detail_card->icon_crown);
GPoint icon_offset = GPoint((cell_layer->bounds.size.w / 2) - (icon_size.w / 2), 4);
gdraw_command_image_draw(ctx, detail_card->icon_crown, icon_offset);
label_rect.origin.y += 8;
}
graphics_context_set_text_color(ctx, GColorBlack);
graphics_draw_text(ctx, zone->label, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), label_rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
}
static int16_t prv_get_cell_height_callback(MenuLayer *menu_layer,
MenuIndex *cell_index, void *context) {
if (cell_index->row == 0) {
return menu_layer_is_index_selected(menu_layer, cell_index) ? DISP_ROWS : 0;
}
return menu_layer_is_index_selected(menu_layer, cell_index) ? 50 : 54;
}
static void prv_refresh_content_indicators(HealthDetailCard *detail_card) {
const bool is_up_visible = (menu_layer_get_selected_index(&detail_card->menu_layer).row > 0);
const bool is_down_visible = (menu_layer_get_selected_index(&detail_card->menu_layer).row <
prv_get_num_rows_callback(&detail_card->menu_layer, 0, detail_card) - 1);
content_indicator_set_content_available(&detail_card->up_indicator,
ContentIndicatorDirectionUp,
is_up_visible);
content_indicator_set_content_available(&detail_card->down_indicator,
ContentIndicatorDirectionDown,
is_down_visible);
}
static void prv_selection_changed_callback(struct MenuLayer *menu_layer, MenuIndex new_index,
MenuIndex old_index, void *context) {
HealthDetailCard *detail_card = (HealthDetailCard *)context;
prv_refresh_content_indicators(detail_card);
}
#else // PBL_RECT
static void prv_health_detail_scroll_layer_update_proc(Layer *layer, GContext *ctx) {
ScrollLayer *scroll_layer = (ScrollLayer *)layer->parent;
HealthDetailCard *detail_card = (HealthDetailCard *)scroll_layer->context;
detail_card->y_origin = 0;
prv_draw_headings(detail_card, ctx, &detail_card->window.layer);
prv_draw_subtitles(detail_card, ctx, &detail_card->window.layer);
prv_draw_zones(detail_card, ctx);
scroll_layer_set_content_size(&detail_card->scroll_layer,
GSize(layer->bounds.size.w, detail_card->y_origin));
}
#endif // PBL_RECT
HealthDetailCard *health_detail_card_create(const HealthDetailCardConfig *config) {
HealthDetailCard *detail_card = app_zalloc_check(sizeof(HealthDetailCard));
window_init(&detail_card->window, WINDOW_NAME("Health Detail Card"));
health_detail_card_configure(detail_card, config);
GRect window_frame = detail_card->window.layer.frame;
#if PBL_ROUND
// setup menu layer
MenuLayer *menu_layer = &detail_card->menu_layer;
menu_layer_init(menu_layer, &window_frame);
menu_layer_set_callbacks(menu_layer, detail_card, &(MenuLayerCallbacks) {
.get_num_rows = prv_get_num_rows_callback,
.get_cell_height = prv_get_cell_height_callback,
.draw_row = prv_draw_row_callback,
.selection_changed = prv_selection_changed_callback,
});
menu_layer_set_normal_colors(menu_layer, detail_card->bg_color, GColorWhite);
menu_layer_set_highlight_colors(menu_layer, detail_card->bg_color, GColorBlack);
menu_layer_set_click_config_onto_window(menu_layer, &detail_card->window);
layer_add_child(&detail_card->window.layer, menu_layer_get_layer(menu_layer));
// setup content indicators
const int content_indicator_height = 15;
const GRect down_arrow_layer_frame = grect_inset(window_frame,
GEdgeInsets(window_frame.size.h - content_indicator_height, 0, 0));
layer_init(&detail_card->down_arrow_layer, &down_arrow_layer_frame);
layer_add_child(&detail_card->window.layer, &detail_card->down_arrow_layer);
content_indicator_init(&detail_card->down_indicator);
const GRect up_arrow_layer_frame = grect_inset(window_frame,
GEdgeInsets(0, 0, window_frame.size.h - content_indicator_height));
layer_init(&detail_card->up_arrow_layer, &up_arrow_layer_frame);
layer_add_child(&detail_card->window.layer, &detail_card->up_arrow_layer);
content_indicator_init(&detail_card->up_indicator);
ContentIndicatorConfig content_indicator_config = (ContentIndicatorConfig) {
.layer = &detail_card->up_arrow_layer,
.colors.foreground = gcolor_legible_over(detail_card->bg_color),
.colors.background = detail_card->bg_color,
};
content_indicator_configure_direction(&detail_card->up_indicator, ContentIndicatorDirectionUp,
&content_indicator_config);
content_indicator_config.layer = &detail_card->down_arrow_layer;
content_indicator_configure_direction(&detail_card->down_indicator, ContentIndicatorDirectionDown,
&content_indicator_config);
prv_refresh_content_indicators(detail_card);
#else // PBL_RECT
// setup scroll layer
scroll_layer_init(&detail_card->scroll_layer, &window_frame);
scroll_layer_set_click_config_onto_window(&detail_card->scroll_layer, &detail_card->window);
scroll_layer_set_context(&detail_card->scroll_layer, detail_card);
scroll_layer_set_shadow_hidden(&detail_card->scroll_layer, true);
layer_add_child(&detail_card->window.layer, (Layer *)&detail_card->scroll_layer);
layer_set_update_proc(&detail_card->scroll_layer.content_sublayer,
prv_health_detail_scroll_layer_update_proc);
#endif // PBL_RECT
detail_card->icon_crown = gdraw_command_image_create_with_resource(RESOURCE_ID_HEALTH_APP_CROWN);
detail_card->heading_label_font = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD);
detail_card->heading_value_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
detail_card->subtitle_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
return detail_card;
}
void health_detail_card_destroy(HealthDetailCard *detail_card) {
if (!detail_card) {
return;
}
gdraw_command_image_destroy(detail_card->icon_crown);
#if PBL_ROUND
menu_layer_deinit(&detail_card->menu_layer);
content_indicator_deinit(&detail_card->down_indicator);
content_indicator_deinit(&detail_card->up_indicator);
#else
scroll_layer_deinit(&detail_card->scroll_layer);
#endif
i18n_free_all(detail_card);
app_free(detail_card);
}
void health_detail_card_configure(HealthDetailCard *detail_card,
const HealthDetailCardConfig *config) {
if (!detail_card || !config) {
return;
}
detail_card->bg_color = config->bg_color;
window_set_background_color(&detail_card->window, detail_card->bg_color);
if (config->num_headings) {
detail_card->num_headings = config->num_headings;
detail_card->headings = config->headings;
}
if (config->num_subtitles) {
detail_card->num_subtitles = config->num_subtitles;
detail_card->subtitles = config->subtitles;
}
detail_card->daily_avg = config->daily_avg;
detail_card->max_progress = MAX(config->weekly_max, detail_card->daily_avg);
detail_card->max_progress = detail_card->max_progress * 11 / 10; // add 10%;
if (config->num_zones) {
detail_card->num_zones = config->num_zones;
detail_card->zones = config->zones;
}
if (config->data) {
detail_card->data = config->data;
}
}
void health_detail_card_set_render_day_zones(HealthDetailZone *zones, int16_t *num_zones,
int32_t *weekly_max, bool format_hours_and_minutes, bool show_crown, GColor fill_color,
GColor today_fill_color, int32_t *day_data, void *i18n_owner) {
time_t time_utc = rtc_get_time();
struct tm time_tm;
int max_data = 0;
int crown_index = 0;
*num_zones = DAYS_PER_WEEK;
for (int i = 0; i < *num_zones; i++) {
localtime_r(&time_utc, &time_tm);
const bool is_today = (i == 0);
const size_t buffer_size = 32;
zones[i] = (HealthDetailZone) {
.label = app_zalloc_check(buffer_size),
.progress = day_data[i],
.fill_color = is_today ? PBL_IF_ROUND_ELSE(fill_color, today_fill_color) : fill_color,
.hide_typical = is_today,
};
char *label_ptr = zones[i].label;
int pos = 0;
if (i == 0) {
pos += snprintf(label_ptr, buffer_size, "%s ", i18n_get("Today", i18n_owner));
} else {
pos += strftime(label_ptr, buffer_size, "%a ", &time_tm);
}
if (day_data[i] > 0) {
if (format_hours_and_minutes) {
health_util_format_hours_and_minutes(label_ptr + pos, buffer_size - pos, day_data[i],
i18n_owner);
} else {
snprintf(label_ptr + pos, buffer_size - pos, "%"PRId32, day_data[i]);
}
}
if (day_data[i] > *weekly_max) {
*weekly_max = day_data[i];
}
if (day_data[i] > max_data) {
max_data = day_data[i];
crown_index = i;
}
time_utc -= SECONDS_PER_DAY;
}
if (crown_index && show_crown) {
zones[crown_index].show_crown = true;
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "health_progress.h"
#include "applib/fonts/fonts.h"
#include "applib/graphics/gdraw_command_image.h"
#include "applib/ui/content_indicator_private.h"
#include "applib/ui/ui.h"
#define MAX_NUM_HEADINGS (2)
#define MAX_NUM_SUBTITLES (2)
#define MAX_NUM_ZONES (7)
typedef struct HealthDetailHeading {
char *primary_label;
char *primary_value;
char *secondary_label;
char *secondary_value;
GColor fill_color;
GColor outline_color;
} HealthDetailHeading;
typedef struct HealthDetailSubtitle {
char *label;
GColor fill_color;
GColor outline_color;
} HealthDetailSubtitle;
typedef struct HealthDetailZone {
char *label;
bool show_crown;
bool hide_typical;
GColor fill_color;
HealthProgressBarValue progress;
} HealthDetailZone;
typedef struct HealthDetailCardConfig {
GColor bg_color;
int16_t num_headings;
HealthDetailHeading *headings;
int16_t num_subtitles;
HealthDetailSubtitle *subtitles;
int32_t daily_avg;
int32_t weekly_max;
int16_t num_zones;
HealthDetailZone *zones;
void *data;
} HealthDetailCardConfig;
typedef struct HealthDetailCard {
Window window;
#if PBL_ROUND
MenuLayer menu_layer;
Layer down_arrow_layer;
Layer up_arrow_layer;
ContentIndicator down_indicator;
ContentIndicator up_indicator;
#else
ScrollLayer scroll_layer;
#endif
GColor bg_color;
int16_t num_headings;
HealthDetailHeading *headings;
int16_t num_subtitles;
HealthDetailSubtitle *subtitles;
GFont heading_label_font;
GFont heading_value_font;
GFont subtitle_font;
GDrawCommandImage *icon_crown;
int32_t daily_avg;
int32_t max_progress;
int16_t num_zones;
HealthDetailZone *zones;
int16_t y_origin;
void *data;
} HealthDetailCard;
//! Creates a HealthDetailCard
HealthDetailCard *health_detail_card_create(const HealthDetailCardConfig *config);
//! Destroys a HealthDetailCard
void health_detail_card_destroy(HealthDetailCard *detail_card);
//! Configures a HealthDetailCard
void health_detail_card_configure(HealthDetailCard *detail_card,
const HealthDetailCardConfig *config);
//! Sets the zones for any daily history (steps/sleep)
//! @param zones pointer to the HealthDetailZone to be set
//! @param num_zones number of zones in the pointer
//! @wparam weekly_max pointer to the weekly max to be set
//! @param format_hours_and_minutes whether to a format the values for hours and minutes in label
//! @param show_crown whether to set the `show_crown` in the zone with weekly max
//! @param fill_color color to fill all the progress bars with except today
//! @param today_fill_color color to fill the today progress bar with
//! @param day_data pointer to the daily history data
//! @param i18n_owner pointer to the i18n owner
void health_detail_card_set_render_day_zones(HealthDetailZone *zones, int16_t *num_zones,
int32_t *weekly_max, bool format_hours_and_minutes, bool show_crown, GColor fill_color,
GColor today_fill_color, int32_t *day_data, void *i18n_owner);

View file

@ -0,0 +1,442 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_graph_card.h"
#include "applib/graphics/graphics.h"
#include "applib/graphics/text.h"
#include "drivers/rtc.h"
#include "kernel/pbl_malloc.h"
#include "services/common/clock.h"
#include "services/common/i18n/i18n.h"
#include "util/math.h"
#include "util/size.h"
#include "util/string.h"
#include "util/time/time.h"
//! Marks where the graph begins
#define GRAPH_OFFSET_Y PBL_IF_RECT_ELSE(38, 48)
//! Marks where the graph ends and where the labels begin
#define LABEL_OFFSET_Y PBL_IF_RECT_ELSE(118, 113)
#define LABEL_HEIGHT 27
#define GRAPH_HEIGHT (LABEL_OFFSET_Y - GRAPH_OFFSET_Y)
#define AVG_LINE_HEIGHT 4
#define AVG_LINE_LEGEND_WIDTH 10
#define AVG_LINE_COLOR GColorYellow
#define INFO_PADDING_BOTTOM 6
//! Get the current day in the standard tm format. Sunday is 0
static uint8_t prv_get_weekday(time_t timestamp) {
return time_util_get_day_in_week(timestamp);
}
static void prv_draw_title(HealthGraphCard *graph_card, GContext *ctx) {
const GRect *bounds = &graph_card->layer.bounds;
graphics_context_set_text_color(ctx, GColorBlack);
const int title_height = 60;
GRect drawing_box = GRect(0, 0, bounds->size.w, title_height);
#if PBL_ROUND
// inset the drawing bounds if on round to account for the bezel
drawing_box = grect_inset(drawing_box, GEdgeInsets(8));
const GSize text_size = graphics_text_layout_get_max_used_size(ctx, graph_card->title,
graph_card->title_font, drawing_box, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// increase drawing box y offset if we're only drawing one line of text
if (text_size.h < 30) {
drawing_box.origin.y += 10;
}
#endif
graphics_draw_text(ctx, graph_card->title, graph_card->title_font, drawing_box,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
static void prv_draw_day_labels_background(HealthGraphCard *graph_card, GContext *ctx) {
const GRect *bounds = &graph_card->layer.bounds;
GRect box = {{ .y = LABEL_OFFSET_Y }, { bounds->size.w, LABEL_HEIGHT }};
graphics_context_set_fill_color(ctx, GColorBlack);
graphics_fill_rect(ctx, &box);
const int border_width = 3;
box = grect_inset(box, GEdgeInsets(border_width, 0));
const GColor label_background_color = GColorWhite;
graphics_context_set_fill_color(ctx, label_background_color);
graphics_fill_rect(ctx, &box);
}
//! Get the corresponding data point for a weekday.
//! Sunday is 0, and the day data begins with today and continues into the past.
static int32_t prv_get_day_point(HealthGraphCard *graph_card, int weekday) {
const int index = positive_modulo(graph_card->current_day - weekday, DAYS_PER_WEEK);
return graph_card->day_data[index];
}
static int32_t prv_convert_to_graph_height(HealthGraphCard *graph_card, int32_t point) {
// Round up in order to show the minimum stub bar once progress begins
int bar_height = (point * GRAPH_HEIGHT + graph_card->data_max - 1) / graph_card->data_max;
const int minimum_stub_height = 5;
if (bar_height > 0 && bar_height < minimum_stub_height) {
// Show the minimum stub bar if progress just began
bar_height = minimum_stub_height;
}
return bar_height;
}
static void prv_setup_day_bar_box(int weekday, GRect *box, int16_t bar_height) {
const int w = 23; // normal bar width;
#if PBL_RECT
// The center bars are slightly wider than the other bars
// Note that Thursday is the center bar, not Wednesday since drawing begins with Monday
// S M T W T F S
const int bar_widths[DAYS_PER_WEEK] = { w, w, w, w + 1, w + 1, w + 1, w };
const int bar_width = bar_widths[weekday];
#else
const int bar_width = w;
#endif
box->origin.y = LABEL_OFFSET_Y - bar_height;
box->size = GSize(bar_width, bar_height);
}
static void prv_draw_day_bar_wide(GContext *ctx, const GRect *box, const GRect *box_inset,
GColor bar_color) {
const GColor border_color = GColorBlack;
graphics_context_set_fill_color(ctx, border_color);
graphics_fill_rect(ctx, box);
graphics_context_set_fill_color(ctx, bar_color);
graphics_fill_rect(ctx, box_inset);
}
static void prv_draw_day_bar_thin(GContext *ctx, const GRect *box, int weekday,
GColor bar_color) {
GRect thin_box = *box;
// Nudge the bars before Thursday (inclusive). Note that Sunday is on the right side, at the end
const int thin_offset_x = WITHIN(weekday, Monday, Thursday) ? 1 : 0;
const int thin_width = 5;
thin_box.origin.x += thin_offset_x + (box->size.w - thin_width) / 2;
thin_box.size.w = thin_width;
graphics_context_set_fill_color(ctx, bar_color);
graphics_fill_rect(ctx, &thin_box);
}
static int16_t prv_draw_day_bar(GContext *ctx, int weekday, const GRect *box,
GColor bar_color, bool wide_bar) {
const int bar_inset = 3;
GRect box_inset = grect_inset(*box, GEdgeInsets(bar_inset, bar_inset, 0, bar_inset));
if (wide_bar) {
prv_draw_day_bar_wide(ctx, box, &box_inset, bar_color);
} else {
prv_draw_day_bar_thin(ctx, box, weekday, bar_color);
}
// The borders of the boxes caused by the inset need to overlap each other
return box->origin.x + box->size.w - bar_inset;
}
static bool prv_bar_should_be_wide(int draw_weekday, int current_weekday) {
// The graph begins on Monday, so all bars from Monday until current (inclusive) should be wide
return (positive_modulo(draw_weekday - Monday, DAYS_PER_WEEK) <=
positive_modulo(current_weekday - Monday, DAYS_PER_WEEK));
}
static GColor prv_get_bar_color(HealthGraphCard *graph_card, bool is_active, bool is_wide) {
const GColor active_color = GColorWhite;
const GColor inactive_wide_color = GColorDarkGray;
const GColor inactive_thin_color = graph_card->inactive_color;
return (is_active ? active_color : (is_wide ? inactive_wide_color : inactive_thin_color));
}
static void prv_draw_day_bars(HealthGraphCard *graph_card, GContext *ctx) {
// With values from prv_setup_day_bar_box and prv_draw_day_bar,
// total_bar_width is sum(bar_widths) - (bar_inset * (DAYS_PER_WEEK - 1))
const int total_bar_widths = PBL_IF_RECT_ELSE(144, 141);
const int legend_line_height = fonts_get_font_height(graph_card->legend_font);
const GRect *bounds = &graph_card->layer.bounds;
GRect box = { .origin.x = (bounds->size.w - total_bar_widths) / 2, .origin.y = LABEL_OFFSET_Y };
// The first day to draw is Monday, and draw a week's worth of bars
for (int i = Monday, draw_count = 0;
draw_count < DAYS_PER_WEEK;
draw_count++, i = (i + 1) % DAYS_PER_WEEK) {
// Setup the dimensions and color of the day bar
const int32_t day_point = prv_get_day_point(graph_card, i);
const int bar_height = prv_convert_to_graph_height(graph_card, day_point);
const bool is_active = (graph_card->selection == i);
if (graph_card->current_day == i) {
// Draw last week's bar as a thin bar behind this bar
const int32_t last_bar_height =
prv_convert_to_graph_height(graph_card, graph_card->day_data[DAYS_PER_WEEK]);
prv_setup_day_bar_box(i, &box, last_bar_height);
const GColor bar_color = prv_get_bar_color(graph_card, is_active, false /* wide bar */);
prv_draw_day_bar(ctx, i, &box, bar_color, false /* wide bar */);
}
// Draw the day bar
prv_setup_day_bar_box(i, &box, bar_height);
const bool is_wide = prv_bar_should_be_wide(i, graph_card->current_day);
const GColor bar_color = prv_get_bar_color(graph_card, is_active, is_wide);
const int16_t next_x = prv_draw_day_bar(ctx, i, &box, bar_color, is_wide);
// Draw the day character legend
const int char_offset_y = 1;
box.origin.y = LABEL_OFFSET_Y + char_offset_y;
box.size.h = legend_line_height;
char char_buffer[] = { graph_card->day_chars[i], '\0' };
const GColor active_legend_color = GColorRed;
const GColor inactive_legend_color = GColorBlack;
graphics_context_set_text_color(ctx, is_active ? active_legend_color : inactive_legend_color);
graphics_draw_text(ctx, char_buffer, graph_card->legend_font, box,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
// Move the box cursor to the next bar
box.origin.x = next_x;
}
}
static void prv_draw_avg_line(HealthGraphCard *graph_card, GContext *ctx, int32_t avg,
int16_t offset_x, int16_t width) {
if (avg == 0) {
return;
}
const int offset_y = LABEL_OFFSET_Y - MAX(prv_convert_to_graph_height(graph_card, avg),
AVG_LINE_HEIGHT / 2);
graphics_context_set_fill_color(ctx, AVG_LINE_COLOR);
graphics_fill_rect(ctx, &(GRect) {{ offset_x, offset_y - AVG_LINE_HEIGHT / 2 },
{ width, AVG_LINE_HEIGHT }});
}
static void prv_draw_avg_lines(HealthGraphCard *graph_card, GContext *ctx) {
const GRect *bounds = &graph_card->layer.bounds;
const int weekday_width = PBL_IF_RECT_ELSE(103, 119);
prv_draw_avg_line(graph_card, ctx, graph_card->stats.weekday.avg, 0, weekday_width);
const int weekend_width = PBL_IF_RECT_ELSE(38, 58);
prv_draw_avg_line(graph_card, ctx, graph_card->stats.weekend.avg, bounds->size.w - weekend_width,
weekend_width);
}
static int32_t prv_get_info_data_point(HealthGraphCard *graph_card) {
// Show today's data point if the selection is a day of the week, otherwise show the weekday
// average if the current day is a weekday or weekend average if the current day is on the weekend
if (graph_card->selection == HealthGraphIndex_Average) {
return IS_WEEKDAY(graph_card->current_day) ? graph_card->stats.weekday.avg :
graph_card->stats.weekend.avg;
}
int day_point = prv_get_day_point(graph_card, graph_card->selection);
if (graph_card->selection == graph_card->current_day && day_point == 0) {
// If today has no progress, use the info from last week
day_point = graph_card->day_data[DAYS_PER_WEEK];
}
return day_point;
}
static void prv_draw_avg_line_legend(HealthGraphCard *graph_card, GContext *ctx, int offset_x,
int info_offset_y, GSize custom_text_size) {
const int info_line_height = fonts_get_font_height(graph_card->legend_font);
const int avg_line_offset_y = -1;
const GRect avg_line_box = {
.origin.x = offset_x,
// Position vertically centered with the text
.origin.y = info_offset_y + (info_line_height + INFO_PADDING_BOTTOM) / 2 + avg_line_offset_y,
.size = { AVG_LINE_LEGEND_WIDTH, AVG_LINE_HEIGHT },
};
graphics_context_set_fill_color(ctx, AVG_LINE_COLOR);
graphics_fill_rect(ctx, &avg_line_box);
}
static void prv_draw_avg_info_text(HealthGraphCard *graph_card, GContext *ctx, int offset_x,
int offset_y, int height) {
const GRect *bounds = &graph_card->layer.bounds;
const GRect avg_text_box = {{ offset_x, offset_y }, { bounds->size.w, height }};
graphics_draw_text(ctx, graph_card->info_avg, graph_card->legend_font, avg_text_box,
GTextOverflowModeWordWrap, GTextAlignmentLeft, NULL);
}
static void prv_draw_custom_info_text(HealthGraphCard *graph_card, GContext *ctx, char *text,
int offset_x, int info_offset_y, int info_height) {
const GRect *bounds = &graph_card->layer.bounds;
const GRect info_text_box = {{ offset_x, info_offset_y }, { bounds->size.w, info_height }};
graphics_context_set_text_color(ctx, GColorBlack);
graphics_draw_text(ctx, text, graph_card->legend_font, info_text_box,
GTextOverflowModeWordWrap, GTextAlignmentLeft, NULL);
}
static bool prv_is_selection_last_weekday(HealthGraphCard *graph_card) {
// If the selection is today, the selection is last week's only if today has no progress
// Else if today is Sunday, the entire graph represents the current week
// Otherwise the selection is last week if either the selection is Sunday
// or if the selection is greater than the current day
return (((int)graph_card->current_day == graph_card->selection && graph_card->day_data[0] == 0) ||
((graph_card->current_day == Sunday) ? false :
((int)graph_card->selection == Sunday ||
(int)graph_card->selection > graph_card->current_day)));
}
size_t health_graph_format_weekday_prefix(HealthGraphCard *graph_card, char *buffer,
size_t buffer_size) {
if (prv_is_selection_last_weekday(graph_card)) {
// The graph starts on Monday, so wrap around the selection and current_day for Sunday
const time_t selection_time =
((positive_modulo(graph_card->selection - Monday, DAYS_PER_WEEK) -
positive_modulo(graph_card->current_day - Monday, DAYS_PER_WEEK) - DAYS_PER_WEEK) *
SECONDS_PER_DAY) + graph_card->data_timestamp;
const int pos = clock_get_month_named_abbrev_date(buffer, buffer_size, selection_time);
strncat(buffer, i18n_get(": ", graph_card), buffer_size - pos - 1);
return strlen(buffer);
} else {
struct tm local_tm = (struct tm) {
.tm_wday = positive_modulo(graph_card->selection, DAYS_PER_WEEK),
};
return strftime(buffer, buffer_size, i18n_get("%a: ", graph_card), &local_tm);
}
}
static void prv_draw_info_with_text(HealthGraphCard *graph_card, GContext *ctx, char *text) {
const GRect *bounds = &graph_card->layer.bounds;
// Calculate the custom info text size
GSize custom_text_size;
const TextLayoutExtended text_layout = {};
graphics_text_layout_get_max_used_size(ctx, text, graph_card->legend_font, *bounds,
GTextOverflowModeWordWrap, GTextAlignmentLeft,
(GTextAttributes *)&text_layout);
custom_text_size = text_layout.max_used_size;
GSize avg_text_size = GSizeZero;
int total_width = custom_text_size.w;
const int info_padding_top = PBL_IF_RECT_ELSE(-1, 1);
const int info_offset_y = LABEL_OFFSET_Y + LABEL_HEIGHT + info_padding_top;
const int info_line_height = fonts_get_font_height(graph_card->legend_font);
const int info_height = PBL_IF_ROUND_ELSE(2, 1) * info_line_height + INFO_PADDING_BOTTOM;
int cursor_x = 0;
if (graph_card->selection == HealthGraphIndex_Average) {
graphics_text_layout_get_max_used_size(ctx, graph_card->info_avg, graph_card->legend_font,
*bounds, GTextOverflowModeWordWrap,
GTextAlignmentLeft, (GTextAttributes *)&text_layout);
avg_text_size = text_layout.max_used_size;
total_width += avg_text_size.w + AVG_LINE_LEGEND_WIDTH;
// Draw the avg line legend
cursor_x = (bounds->size.w - total_width) / 2;
prv_draw_avg_line_legend(graph_card, ctx, cursor_x, info_offset_y, custom_text_size);
cursor_x += AVG_LINE_LEGEND_WIDTH;
// Draw the avg info text
prv_draw_avg_info_text(graph_card, ctx, cursor_x, info_offset_y, info_height);
cursor_x += avg_text_size.w;
} else {
// Center the custom text
cursor_x = (bounds->size.w - total_width) / 2;
}
// Draw the custom info text
prv_draw_custom_info_text(graph_card, ctx, text, cursor_x, info_offset_y, info_height);
}
static void prv_draw_info(HealthGraphCard *graph_card, GContext *ctx) {
if (!graph_card->info_buffer_size) {
return;
}
char buffer[graph_card->info_buffer_size];
memset(buffer, 0, sizeof(buffer));
if (graph_card->info_update) {
const int32_t day_point = prv_get_info_data_point(graph_card);
graph_card->info_update(graph_card, day_point, buffer, sizeof(buffer));
}
if (IS_EMPTY_STRING(buffer)) {
return;
}
prv_draw_info_with_text(graph_card, ctx, buffer);
}
static void prv_health_graph_layer_update_proc(Layer *layer, GContext *ctx) {
HealthGraphCard *graph_card = (HealthGraphCard *)layer;
prv_draw_title(graph_card, ctx);
prv_draw_day_labels_background(graph_card, ctx);
prv_draw_day_bars(graph_card, ctx);
prv_draw_avg_lines(graph_card, ctx);
prv_draw_info(graph_card, ctx);
}
HealthGraphCard *health_graph_card_create(const HealthGraphCardConfig *config) {
HealthGraphCard *graph_card = app_zalloc_check(sizeof(HealthGraphCard));
if (graph_card) {
layer_init(&graph_card->layer, &GRectZero);
layer_set_update_proc(&graph_card->layer, prv_health_graph_layer_update_proc);
health_graph_card_configure(graph_card, config);
graph_card->title_font = fonts_get_system_font(PBL_IF_RECT_ELSE(FONT_KEY_GOTHIC_24_BOLD,
FONT_KEY_GOTHIC_18_BOLD));
graph_card->legend_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
graph_card->current_day = prv_get_weekday(graph_card->data_timestamp);
// The day characters in standard tm weekday order
graph_card->day_chars = i18n_get("SMTWTFS", graph_card);
graph_card->selection = HealthGraphIndex_Average;
}
return graph_card;
}
void health_graph_card_destroy(HealthGraphCard *graph_card) {
if (!graph_card) {
return;
}
layer_deinit(&graph_card->layer);
i18n_free_all(graph_card);
app_free(graph_card);
}
void health_graph_card_configure(HealthGraphCard *graph_card, const HealthGraphCardConfig *config) {
if (!graph_card || !config) {
return;
}
if (config->title) {
graph_card->title = i18n_get(config->title, graph_card);
}
if (config->info_avg) {
graph_card->info_avg = i18n_get(config->info_avg, graph_card);
}
if (config->graph_data) {
graph_card->stats = config->graph_data->stats;
memcpy(graph_card->day_data, config->graph_data->day_data, sizeof(graph_card->day_data));
graph_card->data_timestamp = config->graph_data->timestamp;
graph_card->data_max = MAX(config->graph_data->default_max,
config->graph_data->stats.daily.max);
}
if (config->info_update) {
graph_card->info_update = config->info_update;
}
if (config->info_buffer_size) {
graph_card->info_buffer_size = config->info_buffer_size;
}
if (!gcolor_equal(config->inactive_color, GColorClear)) {
graph_card->inactive_color = config->inactive_color;
}
}
void health_graph_card_cycle_selected(HealthGraphCard *graph_card) {
if (graph_card->selection == HealthGraphIndex_Sunday) {
// Sunday is the last day in the graph, show the average next
graph_card->selection = HealthGraphIndex_Average;
} else if (graph_card->selection == HealthGraphIndex_Average) {
// Monday is the first day in the graph, show Monday after showing the average
graph_card->selection = HealthGraphIndex_Monday;
} else {
// Otherwise progress through the weekdays normally
graph_card->selection = (graph_card->selection + 1) % DAYS_PER_WEEK;
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/layer.h"
#include "util/time/time.h"
typedef enum {
HealthGraphIndex_Sunday = Sunday,
HealthGraphIndex_Monday = Monday,
HealthGraphIndex_Saturday = Saturday,
HealthGraphIndex_Average = HealthGraphIndex_Sunday + DAYS_PER_WEEK,
HealthGraphIndexCount,
} HealthGraphIndex;
typedef struct HealthGraphCard HealthGraphCard;
typedef void (*HealthGraphCardInfoUpdate)(HealthGraphCard *graph_card, int32_t day_point,
char *buffer, size_t buffer_size);
typedef struct {
WeeklyStats stats;
time_t timestamp;
int32_t *day_data;
int32_t default_max;
} HealthGraphCardData;
typedef struct {
const char *title;
const char *info_avg;
const HealthGraphCardData *graph_data;
HealthGraphCardInfoUpdate info_update;
size_t info_buffer_size;
const GColor inactive_color;
} HealthGraphCardConfig;
struct HealthGraphCard {
Layer layer;
WeeklyStats stats;
//! Today is 0. Save up to and including last week's day of the same week day
int32_t day_data[DAYS_PER_WEEK + 1];
time_t data_timestamp; //!< Time at which the data applies in UTC seconds
int32_t data_max;
GFont title_font;
GFont legend_font;
const char *day_chars;
const char *title;
const char *info_avg;
GColor inactive_color;
HealthGraphCardInfoUpdate info_update;
size_t info_buffer_size;
uint8_t current_day; //!< Current weekday (weekend inclusive) where Sunday is first at 0
HealthGraphIndex selection;
};
//! Creates a HealthGraphCard
HealthGraphCard *health_graph_card_create(const HealthGraphCardConfig *config);
//! Destroys a HealthGraphCard
void health_graph_card_destroy(HealthGraphCard *graph_card);
//! Configures a HealthGraphCard
void health_graph_card_configure(HealthGraphCard *graph_card, const HealthGraphCardConfig *config);
//! Cycles the HealthGraphCard selection
void health_graph_card_cycle_selected(HealthGraphCard *graph_card);
//! Formats a string with a prefix of the current weekday selection
size_t health_graph_format_weekday_prefix(HealthGraphCard *graph_card, char *buffer,
size_t buffer_size);

View file

@ -0,0 +1,126 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_hr_detail_card.h"
#include "health_detail_card.h"
#include "kernel/pbl_malloc.h"
#include "services/common/clock.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/activity.h"
#include "services/normal/activity/health_util.h"
#include <stdio.h>
typedef struct HealthHrDetailCard {
int16_t num_headings;
HealthDetailHeading headings[MAX_NUM_HEADINGS];
int16_t num_zones;
HealthDetailZone zones[MAX_NUM_ZONES];
} HealthHrDetailCardData;
#define DEFAULT_MAX_PROGRESS (10 * SECONDS_PER_MINUTE)
static void prv_set_zone(HealthDetailZone *zone, int32_t minutes, int32_t *max_progress,
const size_t buffer_size, const char *zone_label, void *i18n_owner) {
*zone = (HealthDetailZone) {
.label = app_zalloc_check(buffer_size),
.progress = minutes * SECONDS_PER_MINUTE,
.fill_color = PBL_IF_COLOR_ELSE(GColorSunsetOrange, GColorDarkGray),
};
int pos = snprintf(zone->label, buffer_size, "%s ", i18n_get(zone_label, i18n_owner));
if (zone->progress) {
health_util_format_hours_and_minutes(zone->label + pos, buffer_size - pos,
zone->progress, i18n_owner);
}
if (zone->progress > *max_progress) {
*max_progress = zone->progress;
}
}
static void prv_set_heading_value(char *buffer, const size_t buffer_size, const int32_t zone_time_s,
void *i18n_owner) {
if (zone_time_s == 0) {
strncpy(buffer, EN_DASH, buffer_size);
return;
}
health_util_format_hours_and_minutes(buffer, buffer_size, zone_time_s, i18n_owner);
}
Window *health_hr_detail_card_create(HealthData *health_data) {
HealthHrDetailCardData *card_data = app_zalloc_check(sizeof(HealthHrDetailCardData));
const int32_t zone1_minutes = health_data_hr_get_zone1_minutes(health_data);
const int32_t zone2_minutes = health_data_hr_get_zone2_minutes(health_data);
const int32_t zone3_minutes = health_data_hr_get_zone3_minutes(health_data);
const int32_t zone_time_minutes = zone1_minutes + zone2_minutes + zone3_minutes;
int32_t max_progress = DEFAULT_MAX_PROGRESS;
const size_t buffer_size = 32;
prv_set_zone(&card_data->zones[card_data->num_zones++], zone1_minutes, &max_progress, buffer_size,
i18n_noop("Fat Burn"), card_data);
prv_set_zone(&card_data->zones[card_data->num_zones++], zone2_minutes, &max_progress, buffer_size,
i18n_noop("Endurance"), card_data);
prv_set_zone(&card_data->zones[card_data->num_zones++], zone3_minutes, &max_progress, buffer_size,
i18n_noop("Performance"), card_data);
HealthDetailHeading *heading = &card_data->headings[card_data->num_headings++];
*heading = (HealthDetailHeading) {
/// Resting HR
.primary_label = (char *)i18n_get("TIME IN ZONES", card_data),
.primary_value = app_zalloc_check(buffer_size),
.fill_color = GColorWhite,
.outline_color = PBL_IF_COLOR_ELSE(GColorClear, GColorBlack),
};
prv_set_heading_value(heading->primary_value, buffer_size,
(zone_time_minutes * SECONDS_PER_MINUTE), card_data);
const HealthDetailCardConfig config = {
.num_headings = card_data->num_headings,
.headings = card_data->headings,
.weekly_max = max_progress,
.bg_color = PBL_IF_COLOR_ELSE(GColorBulgarianRose, GColorWhite),
.num_zones = card_data->num_zones,
.zones = card_data->zones,
.data = card_data,
};
return (Window *)health_detail_card_create(&config);
}
void health_hr_detail_card_destroy(Window *window) {
HealthDetailCard *card = (HealthDetailCard *)window;
HealthHrDetailCardData *card_data = card->data;
for (int i = 0; i < card_data->num_headings; i++) {
app_free(card_data->headings[i].primary_value);
}
for (int i = 0; i < card_data->num_zones; i++) {
app_free(card_data->zones[i].label);
}
i18n_free_all(card_data);
app_free(card_data);
health_detail_card_destroy(card);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
//! Creates a health hr detail window
//! @param HealthData pointer to the health data to be given to this card
//! @return A pointer to a newly allocated health hr detail window
Window *health_hr_detail_card_create(HealthData *health_data);
//! Destroys a health hr detail window
//! @param window Window pointer to health hr detail window
void health_hr_detail_card_destroy(Window *window);

View file

@ -0,0 +1,254 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_hr_summary_card.h"
#include "health_hr_summary_card_segments.h"
#include "health_hr_detail_card.h"
#include "health_progress.h"
#include "services/normal/activity/health_util.h"
#include "applib/pbl_std/pbl_std.h"
#include "applib/ui/kino/kino_reel.h"
#include "applib/ui/text_layer.h"
#include "apps/system_apps/timeline/text_node.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/clock.h"
#include "services/common/i18n/i18n.h"
#include "system/logging.h"
#include "util/size.h"
#include "util/string.h"
typedef struct HealthHrSummaryCardData {
HealthData *health_data;
HealthProgressBar progress_bar;
GDrawCommandSequence *pulsing_heart;
uint32_t pulsing_heart_frame_index;
AppTimer *pulsing_heart_timer;
uint32_t num_heart_beats;
uint32_t now_bpm;
uint32_t resting_bpm;
time_t last_updated;
GFont bpm_font;
GFont timestamp_font;
GFont units_font;
} HealthHrSummaryCardData;
#define PULSING_HEART_TIMEOUT (30 * MS_PER_SECOND)
#define PROGRESS_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorRoseVale, GColorBlack))
#define PROGRESS_OUTLINE_COLOR (PBL_IF_COLOR_ELSE(GColorClear, GColorBlack))
#define TEXT_COLOR (PBL_IF_COLOR_ELSE(GColorSunsetOrange, GColorBlack))
#define CARD_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorBulgarianRose, GColorWhite))
static void prv_pulsing_heart_timer_cb(void *context) {
Layer *base_layer = context;
HealthHrSummaryCardData *data = layer_get_data(base_layer);
const uint32_t duration = gdraw_command_sequence_get_total_duration(data->pulsing_heart);
const uint32_t num_frames = gdraw_command_sequence_get_num_frames(data->pulsing_heart);
const uint32_t timer_duration = duration / num_frames;
const uint32_t max_heart_beats = PULSING_HEART_TIMEOUT / duration;
data->pulsing_heart_frame_index++;
if (data->pulsing_heart_frame_index >= num_frames) {
data->pulsing_heart_frame_index = 0;
data->num_heart_beats++;
}
if (data->num_heart_beats < max_heart_beats) {
data->pulsing_heart_timer = app_timer_register(timer_duration,
prv_pulsing_heart_timer_cb,
base_layer);
}
layer_mark_dirty(base_layer);
}
static void prv_render_progress_bar(GContext *ctx, Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_BACKGROUND_COLOR,
0, HEALTH_PROGRESS_BAR_MAX_VALUE);
}
static void prv_render_icon(GContext *ctx, Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
GDrawCommandFrame *frame = gdraw_command_sequence_get_frame_by_index(
data->pulsing_heart, data->pulsing_heart_frame_index);
if (frame) {
const GPoint offset = GPoint(-1, -23);
gdraw_command_frame_draw(ctx, data->pulsing_heart, frame, offset);
return;
}
}
static void prv_render_bpm(GContext *ctx, Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
const int units_offset_y =
fonts_get_font_height(data->bpm_font) - fonts_get_font_height(data->units_font);
GTextNodeHorizontal *horiz_container = graphics_text_node_create_horizontal(MAX_TEXT_NODES);
GTextNodeContainer *container = &horiz_container->container;
horiz_container->horizontal_alignment = GTextAlignmentCenter;
if (data->now_bpm == 0) {
health_util_create_text_node_with_text(
EM_DASH, fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), TEXT_COLOR, container);
} else {
const size_t bpm_size = sizeof("000");
GTextNodeText *number_text_node =
health_util_create_text_node(bpm_size, data->bpm_font, TEXT_COLOR, container);
snprintf((char *)number_text_node->text, bpm_size, "%"PRIu32, data->now_bpm);
GTextNodeText *units_text_node = health_util_create_text_node_with_text(
i18n_get("BPM", base_layer), data->units_font, TEXT_COLOR, container);
units_text_node->node.offset.x += 2;
units_text_node->node.offset.y = units_offset_y;
}
const int offset_y = PBL_IF_RECT_ELSE(101, 109);
graphics_text_node_draw(&container->node, ctx,
&GRect(0, offset_y, base_layer->bounds.size.w,
fonts_get_font_height(data->bpm_font)), NULL, NULL);
graphics_text_node_destroy(&container->node);
}
static void prv_render_timstamp(GContext *ctx, Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
if (data->last_updated <= 0 || data->now_bpm == 0) {
return;
}
const size_t buffer_size = 32;
char buffer[buffer_size];
clock_get_until_time_without_fulltime(buffer, buffer_size, data->last_updated, HOURS_PER_DAY);
const int y = PBL_IF_RECT_ELSE(130, 136);
GRect rect = GRect(0, y, base_layer->bounds.size.w, 35);
#if PBL_RECT
rect = grect_inset(rect, GEdgeInsets(0, 18));
#endif
graphics_context_set_text_color(ctx, TEXT_COLOR);
graphics_draw_text(ctx, buffer, data->timestamp_font,
rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
static void prv_render_hrm_disabled(GContext *ctx, Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
const int y = PBL_IF_RECT_ELSE(100, 109);
GRect rect = GRect(0, y, base_layer->bounds.size.w, 52);
/// HRM disabled
const char *text = i18n_get("Enable heart rate monitoring in the mobile app", base_layer);
graphics_context_set_text_color(ctx, TEXT_COLOR);
graphics_draw_text(ctx, text, data->timestamp_font,
rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
static void prv_base_layer_update_proc(Layer *base_layer, GContext *ctx) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
data->now_bpm = health_data_hr_get_current_bpm(data->health_data);
data->last_updated = health_data_hr_get_last_updated_timestamp(data->health_data);
prv_render_icon(ctx, base_layer);
prv_render_progress_bar(ctx, base_layer);
if (!activity_prefs_heart_rate_is_enabled()) {
prv_render_hrm_disabled(ctx, base_layer);
return;
}
prv_render_bpm(ctx, base_layer);
prv_render_timstamp(ctx, base_layer);
}
static void prv_hr_detail_card_unload_callback(Window *window) {
health_hr_detail_card_destroy(window);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
Layer *health_hr_summary_card_create(HealthData *health_data) {
// create base layer
Layer *base_layer = layer_create_with_data(GRectZero, sizeof(HealthHrSummaryCardData));
HealthHrSummaryCardData *data = layer_get_data(base_layer);
layer_set_update_proc(base_layer, prv_base_layer_update_proc);
// set health data
*data = (HealthHrSummaryCardData) {
.health_data = health_data,
.pulsing_heart =
gdraw_command_sequence_create_with_resource(RESOURCE_ID_HEALTH_APP_PULSING_HEART),
.progress_bar = {
.num_segments = ARRAY_LENGTH(s_hr_summary_progress_segments),
.segments = s_hr_summary_progress_segments,
},
.now_bpm = health_data_hr_get_current_bpm(health_data),
.resting_bpm = health_data_hr_get_resting_bpm(health_data),
.last_updated = health_data_hr_get_last_updated_timestamp(health_data),
.bpm_font = fonts_get_system_font(FONT_KEY_LECO_26_BOLD_NUMBERS_AM_PM),
.timestamp_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
.units_font = fonts_get_system_font(FONT_KEY_LECO_20_BOLD_NUMBERS),
};
data->pulsing_heart_timer = app_timer_register(0, prv_pulsing_heart_timer_cb, base_layer);
return base_layer;
}
void health_hr_summary_card_select_click_handler(Layer *layer) {
HealthHrSummaryCardData *data = layer_get_data(layer);
HealthData *health_data = data->health_data;
Window *window = health_hr_detail_card_create(health_data);
window_set_window_handlers(window, &(WindowHandlers) {
.unload = prv_hr_detail_card_unload_callback,
});
app_window_stack_push(window, true);
}
void health_hr_summary_card_destroy(Layer *base_layer) {
HealthHrSummaryCardData *data = layer_get_data(base_layer);
app_timer_cancel(data->pulsing_heart_timer);
gdraw_command_sequence_destroy(data->pulsing_heart);
i18n_free_all(base_layer);
layer_destroy(base_layer);
}
GColor health_hr_summary_card_get_bg_color(Layer *layer) {
return CARD_BACKGROUND_COLOR;
}
bool health_hr_summary_show_select_indicator(Layer *layer) {
return true;
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
//! Creates hr summary card with data
//! @param health_data A pointer to the health data being given this card
//! @return A pointer to a newly allocated layer, which contains its own data
Layer *health_hr_summary_card_create(HealthData *health_data);
//! Health hr summary select click handler
//! @param layer A pointer to an existing layer containing its own data
void health_hr_summary_card_select_click_handler(Layer *layer);
//! Destroy hr summary card
//! @param base_layer A pointer to an existing layer containing its own data
void health_hr_summary_card_destroy(Layer *base_layer);
//! Health hr summary layer background color getter
GColor health_hr_summary_card_get_bg_color(Layer *layer);
//! Health hr summary layer should show select click indicator
bool health_hr_summary_show_select_indicator(Layer *layer);

View file

@ -0,0 +1,83 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_progress.h"
//! 4 main segments + 4 real corners
//! Each of the 4 non-corener segments get 25% of the total
#define AMOUNT_PER_SEGMENT (HEALTH_PROGRESS_BAR_MAX_VALUE * 25 / 100)
// The shape is the same, but the offsets are different
// Slightly adjust the points on Round
#define X_SHIFT (PBL_IF_ROUND_ELSE(18, 0))
#define Y_SHIFT (PBL_IF_ROUND_ELSE(6, 0))
static HealthProgressSegment s_hr_summary_progress_segments[] = {
{
// Bottom corner
.type = HealthProgressSegmentType_Corner,
.points = {{71 + X_SHIFT, 88 + Y_SHIFT}, {64 + X_SHIFT, 94 + Y_SHIFT},
{72 + X_SHIFT, 101 + Y_SHIFT}, {80 + X_SHIFT, 93 + Y_SHIFT}},
},
{
// Left side bottom
.amount_of_total = AMOUNT_PER_SEGMENT,
.type = HealthProgressSegmentType_Vertical,
.points = {{65 + X_SHIFT, 95 + Y_SHIFT}, {72 + X_SHIFT, 89 + Y_SHIFT},
{42 + X_SHIFT, 58 + Y_SHIFT}, {35 + X_SHIFT, 65 + Y_SHIFT}},
},
{
// Left corner
.type = HealthProgressSegmentType_Corner,
.points = {{43 + X_SHIFT, 58 + Y_SHIFT}, {36 + X_SHIFT, 50 + Y_SHIFT},
{29 + X_SHIFT, 58 + Y_SHIFT}, {36 + X_SHIFT, 66 + Y_SHIFT}},
},
{
// Left side top
.amount_of_total = AMOUNT_PER_SEGMENT,
.type = HealthProgressSegmentType_Vertical,
.points = {{36 + X_SHIFT, 51 + Y_SHIFT}, {44 + X_SHIFT, 58 + Y_SHIFT},
{72 + X_SHIFT, 29 + Y_SHIFT}, {65 + X_SHIFT, 22 + Y_SHIFT}},
},
{
// Top corner
.type = HealthProgressSegmentType_Corner,
.points = {{71 + X_SHIFT, 30 + Y_SHIFT}, {79 + X_SHIFT, 23 + Y_SHIFT},
{71 + X_SHIFT, 16 + Y_SHIFT}, {65 + X_SHIFT, 22 + Y_SHIFT}},
},
{
// Right side top
.amount_of_total = AMOUNT_PER_SEGMENT,
.type = HealthProgressSegmentType_Vertical,
.points = {{78 + X_SHIFT, 22 + Y_SHIFT}, {71 + X_SHIFT, 28 + Y_SHIFT},
{102 + X_SHIFT, 60 + Y_SHIFT}, {108 + X_SHIFT, 53 + Y_SHIFT}},
},
{
// Right corner
.type = HealthProgressSegmentType_Corner,
.points = {{100 + X_SHIFT, 56 + Y_SHIFT}, {108 + X_SHIFT, 66 + Y_SHIFT},
{114 + X_SHIFT, 59 + Y_SHIFT}, {106 + X_SHIFT, 50 + Y_SHIFT}},
},
{
// Right side bottom
.amount_of_total = AMOUNT_PER_SEGMENT,
.type = HealthProgressSegmentType_Vertical,
.points = {{102 + X_SHIFT, 57 + Y_SHIFT}, {108 + X_SHIFT, 64 + Y_SHIFT},
{78 + X_SHIFT, 95 + Y_SHIFT}, {71 + X_SHIFT, 89 + Y_SHIFT}},
},
};

View file

@ -0,0 +1,183 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_progress.h"
#include "applib/graphics/gpath_builder.h"
#include "system/logging.h"
// Scales a total shape offset to an individual segment offset.
// @param total_offset should not be larger than the segment's percent of total
static int prv_total_offset_to_segment_offset(HealthProgressSegment *segment,
int total_offset) {
return total_offset * HEALTH_PROGRESS_BAR_MAX_VALUE / segment->amount_of_total;
}
static bool prv_is_segment_corner(HealthProgressSegment *segment) {
return (segment->type == HealthProgressSegmentType_Corner);
}
static GPointPrecise prv_get_adjusted_gpoint_precise_from_gpoint(GPoint point) {
GPointPrecise pointP = GPointPreciseFromGPoint(point);
// Hack to make it draw 2px lines on b/w
// Note that this shifts it down and to the right, but it works for us.
pointP.x.fraction += FIXED_S16_3_HALF.raw_value;
pointP.y.fraction += FIXED_S16_3_HALF.raw_value;
return pointP;
}
static GPoint prv_get_point_between_points(GPoint p1, GPoint p2, HealthProgressBarValue val) {
int x = p1.x + ((p2.x - p1.x) * val / HEALTH_PROGRESS_BAR_MAX_VALUE);
int y = p1.y + ((p2.y - p1.y) * val / HEALTH_PROGRESS_BAR_MAX_VALUE);
return GPoint(x, y);
}
static void prv_fill_segment(GContext *ctx, HealthProgressSegment *segment, GColor color,
HealthProgressBarValue start, HealthProgressBarValue end) {
GPoint p1, p2, p3, p4;
if (segment->type == HealthProgressSegmentType_Vertical) {
p1 = prv_get_point_between_points(segment->points[0], segment->points[3], start);
p2 = prv_get_point_between_points(segment->points[1], segment->points[2], start);
p3 = prv_get_point_between_points(segment->points[1], segment->points[2], end);
p4 = prv_get_point_between_points(segment->points[0], segment->points[3], end);
} else if (segment->type == HealthProgressSegmentType_Horizontal) {
p1 = prv_get_point_between_points(segment->points[0], segment->points[1], start);
p2 = prv_get_point_between_points(segment->points[3], segment->points[2], start);
p3 = prv_get_point_between_points(segment->points[3], segment->points[2], end);
p4 = prv_get_point_between_points(segment->points[0], segment->points[1], end);
} else {
p1 = segment->points[0];
p2 = segment->points[1];
p3 = segment->points[2];
p4 = segment->points[3];
}
GPathBuilder *builder = gpath_builder_create(5);
gpath_builder_move_to_point(builder, p1);
gpath_builder_line_to_point(builder, p2);
gpath_builder_line_to_point(builder, p3);
gpath_builder_line_to_point(builder, p4);
GPath *path = gpath_builder_create_path(builder);
gpath_builder_destroy(builder);
graphics_context_set_fill_color(ctx, color);
gpath_draw_filled(ctx, path);
gpath_destroy(path);
}
void health_progress_bar_fill(GContext *ctx, HealthProgressBar *progress_bar, GColor color,
HealthProgressBarValue start, HealthProgressBarValue end) {
if (start < 0) {
// This ensures we don't deal with negative values
start += HEALTH_PROGRESS_BAR_MAX_VALUE;
}
if (start > end) {
// This ensures the end is always after the start
end += HEALTH_PROGRESS_BAR_MAX_VALUE;
}
int amount_traversed = 0;
HealthProgressSegment *segment = progress_bar->segments;
while (start >= amount_traversed + segment->amount_of_total) {
// Skip until the segment which includes the start
amount_traversed += segment->amount_of_total;
segment++;
}
if (prv_is_segment_corner(segment)) {
segment++;
}
while (amount_traversed < end) {
if (prv_is_segment_corner(segment)) {
// Fully fill corner segments for now
prv_fill_segment(ctx, segment, color, 0, HEALTH_PROGRESS_BAR_MAX_VALUE);
segment++;
continue;
}
const int from_total = MAX(start, amount_traversed) - amount_traversed;
const int to_total = MIN(end, amount_traversed + segment->amount_of_total) - amount_traversed;
const int from = prv_total_offset_to_segment_offset(segment, from_total);
const int to = prv_total_offset_to_segment_offset(segment, to_total);
prv_fill_segment(ctx, segment, color, from, to);
amount_traversed += segment->amount_of_total;
if (segment == &progress_bar->segments[progress_bar->num_segments - 1]) {
// We are on the last segment, wrap back to the first
segment = &progress_bar->segments[0];
} else {
segment++;
}
}
}
void health_progress_bar_mark(GContext *ctx, HealthProgressBar *progress_bar, GColor color,
HealthProgressBarValue value_to_mark) {
if (value_to_mark < 0) {
// This ensures we don't deal with negative values
value_to_mark += HEALTH_PROGRESS_BAR_MAX_VALUE;
}
HealthProgressSegment *segment = progress_bar->segments;
while (value_to_mark > segment->amount_of_total) {
value_to_mark -= segment->amount_of_total;
segment++;
}
if (prv_is_segment_corner(segment)) {
segment++;
}
const int from = prv_total_offset_to_segment_offset(segment, value_to_mark);
// Fill backwards if we can, otherwise forwards
const int dir = value_to_mark - segment->mark_width < 0 ? 1 : -1;
const int to_total = value_to_mark + (dir * segment->mark_width);
const int to = prv_total_offset_to_segment_offset(segment, to_total);
prv_fill_segment(ctx, segment, color, from, to);
}
void health_progress_bar_outline(GContext *ctx, HealthProgressBar *progress_bar, GColor color) {
graphics_context_set_stroke_color(ctx, color);
graphics_context_set_stroke_width(ctx, 2);
for (int i = 0; i < progress_bar->num_segments; i++) {
HealthProgressSegment *segment = &progress_bar->segments[i];
GPointPrecise p0 = prv_get_adjusted_gpoint_precise_from_gpoint(segment->points[0]);
GPointPrecise p1 = prv_get_adjusted_gpoint_precise_from_gpoint(segment->points[1]);
GPointPrecise p2 = prv_get_adjusted_gpoint_precise_from_gpoint(segment->points[2]);
GPointPrecise p3 = prv_get_adjusted_gpoint_precise_from_gpoint(segment->points[3]);
if (segment->type == HealthProgressSegmentType_Vertical) {
graphics_line_draw_precise_stroked(ctx, p0, p3);
graphics_line_draw_precise_stroked(ctx, p1, p2);
} else if (segment->type == HealthProgressSegmentType_Horizontal) {
graphics_line_draw_precise_stroked(ctx, p0, p1);
graphics_line_draw_precise_stroked(ctx, p2, p3);
} else {
graphics_line_draw_precise_stroked(ctx, p1, p2);
graphics_line_draw_precise_stroked(ctx, p2, p3);
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "applib/ui/ui.h"
#define HEALTH_PROGRESS_BAR_MAX_VALUE 0xfff
typedef int32_t HealthProgressBarValue;
typedef enum HealthProgressSegmentType {
HealthProgressSegmentType_Horizontal,
HealthProgressSegmentType_Vertical,
HealthProgressSegmentType_Corner,
HealthProgressSegmentTypeCount,
} HealthProgressSegmentType;
typedef struct HealthProgressSegment {
HealthProgressSegmentType type;
// The amount of the total progress bar that this segment occupies.
// Summing this value over all segments should total HEALTH_PROGRESS_BAR_MAX_VALUE
int amount_of_total;
int mark_width;
GPoint points[4];
} HealthProgressSegment;
typedef struct HealthProgressBar {
int num_segments;
HealthProgressSegment *segments;
} HealthProgressBar;
void health_progress_bar_fill(GContext *ctx, HealthProgressBar *progress_bar, GColor color,
HealthProgressBarValue start, HealthProgressBarValue end);
void health_progress_bar_mark(GContext *ctx, HealthProgressBar *progress_bar, GColor color,
HealthProgressBarValue value_to_mark);
void health_progress_bar_outline(GContext *ctx, HealthProgressBar *progress_bar, GColor color);

View file

@ -0,0 +1,181 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_sleep_detail_card.h"
#include "health_detail_card.h"
#include "services/normal/activity/health_util.h"
#include "kernel/pbl_malloc.h"
#include "services/common/clock.h"
#include "services/common/i18n/i18n.h"
#include <stdio.h>
typedef struct HealthSleepDetailCard {
int32_t daily_avg;
int32_t weekly_max;
int16_t num_headings;
HealthDetailHeading headings[MAX_NUM_HEADINGS];
int16_t num_subtitles;
HealthDetailSubtitle subtitles[MAX_NUM_SUBTITLES];
int16_t num_zones;
HealthDetailZone zones[MAX_NUM_ZONES];
} HealthSleepDetailCardData;
static void prv_set_sleep_session(char *buffer, size_t buffer_size, int32_t sleep_start,
int32_t sleep_end) {
// We don't have a sleep session if either start or end time is not greater than 0.
// Sometimes if there's no sleep, sleep session rendered as "16:00 - 16:00" so for a quick
// fix, we assume there's no sleep if the start and end times are the same.
// https://pebbletechnology.atlassian.net/browse/PBL-40031
if (sleep_start <= 0 || sleep_end <= 0 || sleep_start == sleep_end) {
strncpy(buffer, EN_DASH, buffer_size);
return;
}
const int start_hours = sleep_start / SECONDS_PER_HOUR;
const int start_minutes = (sleep_start % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
const int end_hours = sleep_end / SECONDS_PER_HOUR;
const int end_minutes = (sleep_end % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
int pos = 0;
pos += clock_format_time(buffer + pos, buffer_size - pos,
start_hours, start_minutes, false);
pos += snprintf(buffer + pos, buffer_size - pos, " %s ", "-");
pos += clock_format_time(buffer + pos, buffer_size - pos,
end_hours, end_minutes, false);
}
static void prv_set_deep_sleep(char *buffer, size_t buffer_size, int32_t sleep_duration,
void *i18n_owner) {
if (sleep_duration <= 0) {
strncpy(buffer, EN_DASH, buffer_size);
return;
}
health_util_format_hours_and_minutes(buffer, buffer_size, sleep_duration, i18n_owner);
}
static void prv_set_avg(char *buffer, size_t buffer_size, int32_t daily_avg, void *i18n_owner) {
#if PBL_ROUND
int avg_len = snprintf(buffer, buffer_size, "%s\n", i18n_get("30 DAY AVG", i18n_owner));
#else
int avg_len = snprintf(buffer, buffer_size, "%s ", i18n_get("30 DAY", i18n_owner));
#endif
if (daily_avg <= 0) {
strncpy(buffer + avg_len, EN_DASH, buffer_size - avg_len);
} else {
health_util_format_hours_and_minutes(buffer + avg_len, buffer_size - avg_len,
daily_avg, i18n_owner);
}
}
Window *health_sleep_detail_card_create(HealthData *health_data) {
HealthSleepDetailCardData *card_data = app_zalloc_check(sizeof(HealthSleepDetailCardData));
card_data->daily_avg = health_data_sleep_get_monthly_average(health_data);
const GColor fill_color = PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorDarkGray);
const GColor today_fill_color = PBL_IF_COLOR_ELSE(GColorElectricBlue, GColorDarkGray);
health_detail_card_set_render_day_zones(card_data->zones,
&card_data->num_zones,
&card_data->weekly_max,
true /* format hours and minutes */,
false /* show crown */,
fill_color,
today_fill_color,
health_data_sleep_get(health_data),
card_data);
const size_t buffer_len = 32;
HealthDetailHeading *heading = &card_data->headings[card_data->num_headings++];
*heading = (HealthDetailHeading) {
.primary_label = (char *)i18n_get("SLEEP SESSION", card_data),
.primary_value = app_zalloc_check(buffer_len),
.fill_color = GColorWhite,
.outline_color = PBL_IF_COLOR_ELSE(GColorClear, GColorBlack),
};
prv_set_sleep_session(heading->primary_value, buffer_len,
health_data_sleep_get_start_time(health_data),
health_data_sleep_get_end_time(health_data));
heading = &card_data->headings[card_data->num_headings++];
*heading = (HealthDetailHeading) {
.primary_label = (char *)i18n_get("DEEP SLEEP", card_data),
.primary_value = app_zalloc_check(buffer_len),
.fill_color = PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorWhite),
#if PBL_BW
.outline_color = GColorBlack,
#endif
};
prv_set_deep_sleep(heading->primary_value, buffer_len,
health_data_current_deep_sleep_get(health_data), card_data);
HealthDetailSubtitle *subtitle = &card_data->subtitles[card_data->num_subtitles++];
*subtitle = (HealthDetailSubtitle) {
.label = app_zalloc_check(buffer_len),
.fill_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack),
};
prv_set_avg(subtitle->label, buffer_len, card_data->daily_avg, card_data);
const HealthDetailCardConfig config = {
.num_headings = card_data->num_headings,
.headings = card_data->headings,
.num_subtitles = card_data->num_subtitles,
.subtitles = card_data->subtitles,
.daily_avg = card_data->daily_avg,
.weekly_max = card_data->weekly_max,
.bg_color = PBL_IF_COLOR_ELSE(GColorOxfordBlue, GColorWhite),
.num_zones = card_data->num_zones,
.zones = card_data->zones,
.data = card_data,
};
return (Window *)health_detail_card_create(&config);
}
void health_sleep_detail_card_destroy(Window *window) {
HealthDetailCard *card = (HealthDetailCard *)window;
HealthSleepDetailCardData *card_data = card->data;
for (int i = 0; i < card_data->num_headings; i++) {
app_free(card_data->headings[i].primary_value);
app_free(card_data->headings[i].secondary_value);
}
for (int i = 0; i < card_data->num_subtitles; i++) {
app_free(card_data->subtitles[i].label);
}
for (int i = 0; i < card_data->num_zones; i++) {
app_free(card_data->zones[i].label);
}
i18n_free_all(card_data);
app_free(card_data);
health_detail_card_destroy(card);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
//! Creates a health sleep detail window
//! @param HealthData pointer to the health data to be given to this card
//! @return A pointer to a newly allocated health sleep detail window
Window *health_sleep_detail_card_create(HealthData *health_data);
//! Destroys a health sleep detail window
//! @param window Window pointer to health sleep detail window
void health_sleep_detail_card_destroy(Window *window);

View file

@ -0,0 +1,305 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_sleep_summary_card.h"
#include "health_sleep_summary_card_segments.h"
#include "health_sleep_detail_card.h"
#include "health_progress.h"
#include "health_ui.h"
#include "services/normal/activity/health_util.h"
#include "applib/pbl_std/pbl_std.h"
#include "applib/ui/kino/kino_layer.h"
#include "applib/ui/text_layer.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "system/logging.h"
#include "util/size.h"
#include "util/string.h"
typedef struct HealthSleepSummaryCardData {
HealthData *health_data;
HealthProgressBar progress_bar;
KinoReel *icon;
GFont number_font;
GFont unit_font;
GFont typical_font;
GFont em_dash_font;
} HealthSleepSummaryCardData;
#define PROGRESS_CURRENT_COLOR (PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorDarkGray))
#define PROGRESS_SECONDARY_COLOR (PBL_IF_COLOR_ELSE(GColorVeryLightBlue, GColorClear))
#define PROGRESS_TYPICAL_COLOR (PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack))
#define PROGRESS_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorDarkGray, GColorClear))
#define PROGRESS_OUTLINE_COLOR (PBL_IF_COLOR_ELSE(GColorClear, GColorBlack))
#define CURRENT_TEXT_COLOR (PBL_IF_COLOR_ELSE(GColorVividCerulean, GColorBlack))
#define TYPICAL_TEXT_COLOR (PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite))
#define NO_DATA_TEXT_COLOR (PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack))
#define CARD_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorOxfordBlue, GColorWhite))
#define TWELVE_HOURS (SECONDS_PER_HOUR * 12)
static void prv_render_sleep_sessions(GContext *ctx, HealthSleepSummaryCardData *data) {
const int num_sessions = health_data_sleep_get_num_sessions(data->health_data);
ActivitySession *sessions = health_data_sleep_get_sessions(data->health_data);
for (int i = 0; i < num_sessions; i++) {
ActivitySession *session = &sessions[i];
GColor fill_color = GColorClear;
if (session->type == ActivitySessionType_Sleep) {
fill_color = PROGRESS_CURRENT_COLOR;
} else if (session->type == ActivitySessionType_RestfulSleep) {
fill_color = PROGRESS_SECONDARY_COLOR;
}
if (gcolor_equal(fill_color, GColorClear)) {
continue;
}
struct tm local_tm;
localtime_r(&session->start_utc, &local_tm);
const int session_start_24h = (local_tm.tm_sec +
local_tm.tm_min * SECONDS_PER_MINUTE +
local_tm.tm_hour * SECONDS_PER_HOUR);
const int session_end_24h = session_start_24h + (session->length_min * SECONDS_PER_MINUTE);
const int session_start_12h = session_start_24h % TWELVE_HOURS;
const int session_end_12h = session_end_24h % TWELVE_HOURS;
const int start = (session_start_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
const int end = (session_end_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
health_progress_bar_fill(ctx, &data->progress_bar, fill_color, start, end);
}
}
static void prv_render_typical_markers(GContext *ctx, HealthSleepSummaryCardData *data) {
// Some time fuzz is applied to a couple values to ensure that typical fill touches the sleep
// sessions (needed because of how our fill algorithms work)
const int time_fuzz = (2 * SECONDS_PER_MINUTE);
const int sleep_start_24h = health_data_sleep_get_start_time(data->health_data);
const int sleep_start_12h = (sleep_start_24h) % TWELVE_HOURS;
const int sleep_end_24h = health_data_sleep_get_end_time(data->health_data);
const int sleep_end_12h = (sleep_end_24h - time_fuzz) % TWELVE_HOURS;
if (sleep_start_24h || sleep_end_24h) {
const int sleep_start = (sleep_start_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
const int sleep_end = (sleep_end_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
const int typical_sleep_start_24h = health_data_sleep_get_typical_start_time(data->health_data);
const int typical_sleep_start_12h = typical_sleep_start_24h % TWELVE_HOURS;
const int typical_sleep_end_24h = health_data_sleep_get_typical_end_time(data->health_data);
const int typical_sleep_end_12h = typical_sleep_end_24h % TWELVE_HOURS;
const int typical_start =
(typical_sleep_start_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
const int typical_end =
(typical_sleep_end_12h * HEALTH_PROGRESS_BAR_MAX_VALUE / TWELVE_HOURS);
#if PBL_COLOR
const bool fell_asleep_late = (typical_sleep_start_24h < sleep_start_24h);
if (fell_asleep_late) {
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR,
typical_start, sleep_start);
} else {
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_start);
}
const bool woke_up_early = (typical_sleep_end_24h > sleep_end_24h);
if (woke_up_early) {
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR,
sleep_end, typical_end);
} else {
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_end);
}
#else
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_start);
health_progress_bar_mark(ctx, &data->progress_bar, PROGRESS_TYPICAL_COLOR, typical_end);
#endif
}
}
static void prv_render_progress_bar(GContext *ctx, Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
// Renders the background
health_progress_bar_fill(ctx, &data->progress_bar, PROGRESS_BACKGROUND_COLOR,
0, HEALTH_PROGRESS_BAR_MAX_VALUE);
prv_render_sleep_sessions(ctx, data);
prv_render_typical_markers(ctx, data);
// This is required to get the rounded corners on the outside of the rectangle
graphics_context_set_stroke_width(ctx, 2);
graphics_context_set_stroke_color(ctx, CARD_BACKGROUND_COLOR);
graphics_draw_round_rect(ctx, &s_sleep_summary_masking_rect, 5);
// This needs to be done after drawing the progress bars or else the progress fill
// overlaps the outline and things look weird
health_progress_bar_outline(ctx, &data->progress_bar, PROGRESS_OUTLINE_COLOR);
}
static void prv_render_icon(GContext *ctx, Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(37, 32), 39);
const int x_center_offset = 17;
kino_reel_draw(data->icon, ctx, GPoint(base_layer->bounds.size.w / 2 - x_center_offset, y));
}
static void prv_render_current_sleep_text(GContext *ctx, Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(85, 83), 88);
const GRect rect = GRect(0, y, base_layer->bounds.size.w, 35);
const int current_sleep = health_data_current_sleep_get(data->health_data);
if (current_sleep) {
// Draw the hours slept
GTextNodeHorizontal *horiz_container = graphics_text_node_create_horizontal(MAX_TEXT_NODES);
GTextNodeContainer *container = &horiz_container->container;
horiz_container->horizontal_alignment = GTextAlignmentCenter;
health_util_duration_to_hours_and_minutes_text_node(current_sleep, base_layer,
data->number_font,
data->unit_font,
CURRENT_TEXT_COLOR, container);
graphics_text_node_draw(&container->node, ctx, &rect, NULL, NULL);
graphics_text_node_destroy(&container->node);
} else {
char buffer[16];
const GFont font = data->em_dash_font;
snprintf(buffer, sizeof(buffer), EM_DASH);
graphics_context_set_text_color(ctx, CURRENT_TEXT_COLOR);
graphics_draw_text(ctx, buffer, font, rect, GTextOverflowModeFill, GTextAlignmentCenter, NULL);
}
}
static void prv_render_typical_sleep_text(GContext *ctx, Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
const int typical_sleep = health_data_sleep_get_cur_wday_average(data->health_data);
char sleep_text[32];
if (typical_sleep) {
health_util_format_hours_and_minutes(sleep_text, sizeof(sleep_text), typical_sleep, base_layer);
} else {
snprintf(sleep_text, sizeof(sleep_text), EM_DASH);
}
health_ui_render_typical_text_box(ctx, base_layer, sleep_text);
}
static void prv_render_no_sleep_data_text(GContext *ctx, Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
const int y = PBL_IF_RECT_ELSE(91, 100);
const GRect rect = GRect(0, y, base_layer->bounds.size.w, 60);
const char *text = i18n_get("No sleep data,\nwear your watch\nto sleep", base_layer);
graphics_context_set_text_color(ctx, NO_DATA_TEXT_COLOR);
graphics_draw_text(ctx, text, data->typical_font,
rect, GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}
static bool prv_has_sleep_data(HealthData *health_data) {
// daily weekly stats doesn't include the first index so we check that separately
return health_data_current_sleep_get(health_data) ||
health_data_sleep_get_monthly_average(health_data) > 0;
}
static void prv_base_layer_update_proc(Layer *base_layer, GContext *ctx) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
prv_render_icon(ctx, base_layer);
prv_render_progress_bar(ctx, base_layer);
if (!prv_has_sleep_data(data->health_data)) {
prv_render_no_sleep_data_text(ctx, base_layer);
return;
}
prv_render_current_sleep_text(ctx, base_layer);
prv_render_typical_sleep_text(ctx, base_layer);
}
static void prv_sleep_detail_card_unload_callback(Window *window) {
health_sleep_detail_card_destroy(window);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
Layer *health_sleep_summary_card_create(HealthData *health_data) {
// create base layer
Layer *base_layer = layer_create_with_data(GRectZero, sizeof(HealthSleepSummaryCardData));
HealthSleepSummaryCardData *health_sleep_summary_card_data = layer_get_data(base_layer);
layer_set_update_proc(base_layer, prv_base_layer_update_proc);
// set health data
*health_sleep_summary_card_data = (HealthSleepSummaryCardData) {
.icon = kino_reel_create_with_resource(RESOURCE_ID_HEALTH_APP_SLEEP),
.progress_bar = {
.num_segments = ARRAY_LENGTH(s_sleep_summary_progress_segments),
.segments = s_sleep_summary_progress_segments,
},
.health_data = health_data,
.number_font = fonts_get_system_font(FONT_KEY_LECO_26_BOLD_NUMBERS_AM_PM),
.unit_font = fonts_get_system_font(FONT_KEY_LECO_20_BOLD_NUMBERS),
.typical_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD),
.em_dash_font = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD),
};
return base_layer;
}
void health_sleep_summary_card_select_click_handler(Layer *layer) {
HealthSleepSummaryCardData *health_sleep_summary_card_data = layer_get_data(layer);
HealthData *health_data = health_sleep_summary_card_data->health_data;
if (prv_has_sleep_data(health_data)) {
Window *window = health_sleep_detail_card_create(health_data);
window_set_window_handlers(window, &(WindowHandlers) {
.unload = prv_sleep_detail_card_unload_callback,
});
app_window_stack_push(window, true);
}
}
void health_sleep_summary_card_destroy(Layer *base_layer) {
HealthSleepSummaryCardData *data = layer_get_data(base_layer);
i18n_free_all(base_layer);
kino_reel_destroy(data->icon);
layer_destroy(base_layer);
}
GColor health_sleep_summary_card_get_bg_color(Layer *layer) {
return CARD_BACKGROUND_COLOR;
}
bool health_sleep_summary_show_select_indicator(Layer *layer) {
HealthSleepSummaryCardData *health_sleep_summary_card_data = layer_get_data(layer);
return prv_has_sleep_data(health_sleep_summary_card_data->health_data);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_data.h"
#include "applib/ui/ui.h"
typedef enum {
SleepSummaryView_Sleep,
SleepSummaryView_DeepSleep,
SleepSummaryView_EndAndWake,
SleepSummaryView_Nap,
SleepSummaryViewCount
} SleepSummaryView;
////////////////////////////////////////////////////////////////////////////////////////////////////
// API Functions
//
//! Creates a layer with extra data
//! @param health_data A pointer to the health data buffer
//! @return A pointer to the newly allocated layer
Layer *health_sleep_summary_card_create(HealthData *health_data);
//! Health activity summary select click handler
//! @param layer A pointer to an existing layer with extra data
void health_sleep_summary_card_select_click_handler(Layer *layer);
//! Set the card to a given view
//! @param view the view type to show
void health_sleep_summary_card_set_view(Layer *layer, SleepSummaryView view);
//! Destroy a layer with extra data
//! @param base_layer A pointer to an existing layer with extra data
void health_sleep_summary_card_destroy(Layer *base_layer);
//! Health sleep summary layer background color getter
GColor health_sleep_summary_card_get_bg_color(Layer *layer);
//! Health sleep summary layer should show select click indicator
bool health_sleep_summary_show_select_indicator(Layer *layer);

View file

@ -0,0 +1,135 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "health_progress.h"
//! 5 main segments + 4 real corners
//! The top bar is split up into 2 segments (12am is the middle of the top bar)
//! Each of line gets 25% of the total (top line split into 2 segments which are 12.5% each)
#define AMOUNT_PER_SEGMENT (HEALTH_PROGRESS_BAR_MAX_VALUE * 25 / 100)
// Found through trial and error
#define DEFAULT_MARK_WIDTH 40
#define X_SHIFT (PBL_IF_ROUND_ELSE(23, PBL_IF_BW_ELSE(1, 0)))
#define Y_SHIFT (PBL_IF_ROUND_ELSE(8, PBL_IF_BW_ELSE(3, 0)))
// Used to shrink the thinkness of the bars
#define X_SHRINK (PBL_IF_BW_ELSE(2, 0))
// These are used to shrink the shape for round
#define X_ADJ (PBL_IF_ROUND_ELSE(-12, PBL_IF_BW_ELSE(-3, 0)))
#define Y_ADJ (PBL_IF_ROUND_ELSE(-3, PBL_IF_BW_ELSE(1, 0)))
static HealthProgressSegment s_sleep_summary_progress_segments[] = {
{
// Top right
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = AMOUNT_PER_SEGMENT / 2,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{71 + X_SHIFT, 22 + Y_SHIFT},
{116 + X_SHRINK + X_SHIFT + X_ADJ, 22 + Y_SHIFT},
{116 + X_SHRINK + X_SHIFT + X_ADJ, 13 + Y_SHIFT},
{71 + X_SHIFT, 13 + Y_SHIFT}},
},
{
// Top right corner
.type = HealthProgressSegmentType_Corner,
.points = {{115 + X_SHRINK + X_SHIFT + X_ADJ, 22 + Y_SHIFT},
{115 + X_SHRINK + X_SHIFT + X_ADJ, 13 + Y_SHIFT},
{127 + X_SHIFT + X_ADJ, 13 + Y_SHIFT},
{127 + X_SHIFT + X_ADJ, 22 + Y_SHIFT}},
},
{
// Right
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH + 10,
.points = {{116 + X_SHRINK + X_SHIFT + X_ADJ, 23 + Y_SHIFT},
{127 + X_SHIFT + X_ADJ, 23 + Y_SHIFT},
{127 + X_SHIFT + X_ADJ, 73 + Y_SHIFT + Y_ADJ},
{116 + X_SHRINK + X_SHIFT + X_ADJ, 73 + Y_SHIFT + Y_ADJ}},
},
{
// Bottom right corner
.type = HealthProgressSegmentType_Corner,
.points = {{115 + X_SHRINK + X_SHIFT + X_ADJ, 74 + Y_SHIFT + Y_ADJ},
{127 + X_SHIFT + X_ADJ, 74 + Y_SHIFT + Y_ADJ},
{127 + X_SHIFT + X_ADJ, 83 + Y_SHIFT + Y_ADJ},
{115 + X_SHRINK + X_SHIFT + X_ADJ, 83 + Y_SHIFT + Y_ADJ}},
},
{
// Bottom
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{116 + X_SHRINK + X_SHIFT + X_ADJ, 74 + Y_SHIFT + Y_ADJ},
{27 + X_SHRINK + X_SHIFT + X_ADJ, 74 + Y_SHIFT + Y_ADJ},
{27 + X_SHRINK + X_SHIFT + X_ADJ, 83 + Y_SHIFT + Y_ADJ},
{116 + X_SHRINK + X_SHIFT + X_ADJ, 83 + Y_SHIFT + Y_ADJ}},
},
{
// Bottom left corner
.type = HealthProgressSegmentType_Corner,
.points = {{29 + -X_SHRINK + X_SHIFT, 74 + Y_SHIFT + Y_ADJ},
{17 + X_SHIFT, 74 + Y_SHIFT + Y_ADJ},
{17 + X_SHIFT, 83 + Y_SHIFT + Y_ADJ},
{29 + -X_SHRINK + X_SHIFT, 83 + Y_SHIFT + Y_ADJ}},
},
{
// Left
.type = HealthProgressSegmentType_Vertical,
.amount_of_total = AMOUNT_PER_SEGMENT,
.mark_width = DEFAULT_MARK_WIDTH,
.points = {{28 + -X_SHRINK + X_SHIFT, 74 + Y_SHIFT + Y_ADJ},
{17 + X_SHIFT, 74 + Y_SHIFT + Y_ADJ},
{17 + X_SHIFT, 23 + Y_SHIFT},
{28 + -X_SHRINK + X_SHIFT, 23 + Y_SHIFT}},
},
{
// Top left corner
.type = HealthProgressSegmentType_Corner,
.points = {{29 + X_SHIFT, 22 + Y_SHIFT},
{17 + X_SHIFT, 22 + Y_SHIFT},
{17 + X_SHIFT, 13 + Y_SHIFT},
{29 + X_SHIFT, 13 + Y_SHIFT}},
},
{
// Top left
.type = HealthProgressSegmentType_Horizontal,
.amount_of_total = AMOUNT_PER_SEGMENT / 2,
.mark_width = DEFAULT_MARK_WIDTH + 10,
.points = {{28 + -X_SHRINK + X_SHIFT, 22 + Y_SHIFT},
{72 + X_SHIFT, 22 + Y_SHIFT},
{72 + X_SHIFT, 13 + Y_SHIFT},
{28 + -X_SHRINK + X_SHIFT, 13 + Y_SHIFT}},
},
};
#define MASKING_RECT_X_SHIFT (X_SHIFT + PBL_IF_BW_ELSE(1, 0))
#define MASKING_RECT_Y_SHIFT (Y_SHIFT + PBL_IF_BW_ELSE(1, 0))
#define MASKING_RECT_X_ADJ (X_ADJ + PBL_IF_BW_ELSE(-1, 0))
#define MASKING_RECT_Y_ADJ (Y_ADJ + PBL_IF_BW_ELSE(-1, 0))
static const GRect s_sleep_summary_masking_rect = {
.origin.x = 16 + MASKING_RECT_X_SHIFT,
.origin.y = 11 + MASKING_RECT_Y_SHIFT,
.size.w = 113 + MASKING_RECT_X_ADJ,
.size.h = 75 + MASKING_RECT_Y_ADJ,
};

View file

@ -0,0 +1,94 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "health_ui.h"
#include "applib/pbl_std/pbl_std.h"
#include "services/common/i18n/i18n.h"
#include "util/string.h"
void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect drawing_bounds,
const int16_t y_offset, const GFont small_font, GColor box_color,
GColor text_color) {
const uint8_t text_height = fonts_get_font_height(small_font);
const GTextOverflowMode overflow_mode = GTextOverflowModeFill;
const GTextAlignment alignment = GTextAlignmentCenter;
const GRect text_box = GRect(drawing_bounds.origin.x, y_offset,
drawing_bounds.size.w, text_height);
GRect text_fill_box = text_box;
text_fill_box.size = app_graphics_text_layout_get_content_size(
text, small_font, text_box, overflow_mode, alignment);
text_fill_box.origin.x += ((drawing_bounds.size.w - text_fill_box.size.w) / 2);
// add a 3 px border (get content size already adds 1 px)
text_fill_box = grect_inset(text_fill_box, GEdgeInsets(-2));
// get content size adds 5 to the height, and the y offset is too high by a px (+ the 5px)
const int height_correction = 5;
text_fill_box.size.h -= height_correction;
text_fill_box.origin.y += height_correction + 1;
if (!gcolor_equal(box_color, GColorClear)) {
graphics_context_set_fill_color(ctx, box_color);
graphics_fill_rect(ctx, &text_fill_box);
}
if (!gcolor_equal(text_color, GColorClear)) {
graphics_context_set_text_color(ctx, text_color);
graphics_draw_text(ctx, text, small_font, text_box, overflow_mode, alignment, NULL);
}
}
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text) {
time_t now = rtc_get_time();
struct tm time_tm;
localtime_r(&now, &time_tm);
char weekday[8];
strftime(weekday, sizeof(weekday), "%a", &time_tm);
toupper_str(weekday);
char typical_text[32];
snprintf(typical_text, sizeof(typical_text), i18n_get("TYPICAL %s", layer), weekday);
const int y = PBL_IF_RECT_ELSE(PBL_IF_BW_ELSE(122, 120), 125);
GRect rect = GRect(0, y, layer->bounds.size.w, PBL_IF_RECT_ELSE(35, 36));
#if PBL_RECT
rect = grect_inset(rect, GEdgeInsets(0, 18));
#endif
const GColor bg_color = PBL_IF_COLOR_ELSE(GColorYellow, GColorBlack);
const GColor text_color = PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite);
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
graphics_context_set_fill_color(ctx, bg_color);
graphics_fill_round_rect(ctx, &rect, 3, GCornersAll);
rect.origin.y -= PBL_IF_RECT_ELSE(3, 2);
// Restrict the rect to draw one line at a time to prevent them from wrapping into each other
rect.size.h = 16;
graphics_context_set_text_color(ctx, text_color);
graphics_draw_text(ctx, typical_text, font, rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
rect.origin.y += 16;
graphics_draw_text(ctx, value_text, font, rect,
GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL);
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "applib/ui/ui.h"
void health_ui_draw_text_in_box(GContext *ctx, const char *text, const GRect drawing_bounds,
const int16_t y_offset, const GFont small_font, GColor box_color,
GColor text_color);
void health_ui_render_typical_text_box(GContext *ctx, Layer *layer, const char *value_text);

View file

@ -0,0 +1,195 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app.h"
#include "launcher_menu_layer.h"
#include "applib/app.h"
#include "applib/app_focus_service.h"
#include "applib/ui/app_window_stack.h"
#include "kernel/pbl_malloc.h"
#include "shell/normal/app_idle_timeout.h"
#include "system/passert.h"
#include "process_state/app_state/app_state.h"
#include "util/attributes.h"
typedef struct LauncherAppWindowData {
Window window;
LauncherMenuLayer launcher_menu_layer;
AppMenuDataSource app_menu_data_source;
} LauncherAppWindowData;
typedef struct LauncherAppPersistedData {
bool valid;
RtcTicks leave_time;
LauncherMenuLayerSelectionState selection_state;
LauncherDrawState draw_state;
} LauncherAppPersistedData;
static LauncherAppPersistedData s_launcher_app_persisted_data;
/////////////////////////////
// AppFocusService handlers
static void prv_did_focus(bool in_focus) {
LauncherAppWindowData *data = app_state_get_user_data();
if (in_focus) {
launcher_menu_layer_set_selection_animations_enabled(&data->launcher_menu_layer, true);
}
}
static void prv_will_focus(bool in_focus) {
LauncherAppWindowData *data = app_state_get_user_data();
if (!in_focus) {
launcher_menu_layer_set_selection_animations_enabled(&data->launcher_menu_layer, false);
}
}
////////////////////////////////
// AppMenuDataSource callbacks
static bool prv_app_filter_callback(UNUSED AppMenuDataSource *source, AppInstallEntry *entry) {
// Skip watchfaces and hidden apps
return (!app_install_entry_is_watchface(entry) && !app_install_entry_is_hidden((entry)));
}
static void prv_data_changed(void *context) {
LauncherAppWindowData *data = context;
launcher_menu_layer_reload_data(&data->launcher_menu_layer);
}
//! We're not 100% sure of the order of the launcher list yet, so use this function to transform
//! the row index to achieve the desired list ordering
static uint16_t prv_transform_index(AppMenuDataSource *data_source, uint16_t original_index,
void *context) {
#if (SHELL_SDK && CAPABILITY_HAS_SDK_SHELL4)
// We want the newest installed developer app to appear at the top
// This works at the moment because there is only one system app, Watchfaces
return app_menu_data_source_get_count(data_source) - 1 - original_index;
#else
return original_index;
#endif
}
/////////////////////
// Window callbacks
static void prv_window_load(Window *window) {
LauncherAppWindowData *data = window_get_user_data(window);
Layer *window_root_layer = window_get_root_layer(window);
AppMenuDataSource *data_source = &data->app_menu_data_source;
app_menu_data_source_init(data_source, &(AppMenuDataSourceCallbacks) {
.changed = prv_data_changed,
.filter = prv_app_filter_callback,
.transform_index = prv_transform_index,
}, data);
LauncherMenuLayer *launcher_menu_layer = &data->launcher_menu_layer;
launcher_menu_layer_init(launcher_menu_layer, data_source);
launcher_menu_layer_set_click_config_onto_window(launcher_menu_layer, window);
layer_add_child(window_root_layer, launcher_menu_layer_get_layer(launcher_menu_layer));
// If we have a saved launcher selection state, restore it
if (s_launcher_app_persisted_data.valid) {
launcher_menu_layer_set_selection_state(launcher_menu_layer,
&s_launcher_app_persisted_data.selection_state);
}
app_focus_service_subscribe_handlers((AppFocusHandlers) {
.did_focus = prv_did_focus,
.will_focus = prv_will_focus,
});
}
static void prv_window_unload(Window *window) {
LauncherAppWindowData *data = window_get_user_data(window);
// Capture the vertical range of the selection rectangle for compositor transition animations
GRangeVertical launcher_selection_vertical_range;
launcher_menu_layer_get_selection_vertical_range(&data->launcher_menu_layer,
&launcher_selection_vertical_range);
// Save the current state of the launcher so we can know its draw state and restore it later
s_launcher_app_persisted_data = (LauncherAppPersistedData) {
.valid = true,
.leave_time = rtc_get_ticks(),
.draw_state.selection_vertical_range = launcher_selection_vertical_range,
.draw_state.selection_background_color = LAUNCHER_MENU_LAYER_SELECTION_BACKGROUND_COLOR,
};
launcher_menu_layer_get_selection_state(&data->launcher_menu_layer,
&s_launcher_app_persisted_data.selection_state);
app_focus_service_unsubscribe();
launcher_menu_layer_deinit(&data->launcher_menu_layer);
app_menu_data_source_deinit(&data->app_menu_data_source);
}
////////////////////
// App boilerplate
static void prv_launcher_menu_window_push(void) {
LauncherAppWindowData *data = app_zalloc_check(sizeof(*data));
app_state_set_user_data(data);
Window *window = &data->window;
window_init(window, WINDOW_NAME("Launcher Menu"));
window_set_user_data(window, data);
window_set_window_handlers(window, &(WindowHandlers) {
.load = prv_window_load,
.unload = prv_window_unload,
});
const bool animated = false;
app_window_stack_push(window, animated);
}
static void prv_main(void) {
const LauncherMenuArgs *args = (const LauncherMenuArgs *)app_manager_get_task_context()->args;
// Reset the selection state of the launcher if we're visiting it for the first time or if
// it has been more than RETURN_TIMEOUT_TICKS since we were last in the launcher
if (args && args->reset_scroll) {
if ((s_launcher_app_persisted_data.leave_time + RETURN_TIMEOUT_TICKS) <= rtc_get_ticks()) {
s_launcher_app_persisted_data.valid = false;
}
}
prv_launcher_menu_window_push();
app_idle_timeout_start();
app_event_loop();
}
const PebbleProcessMd *launcher_menu_app_get_app_info(void) {
static const PebbleProcessMdSystem s_launcher_menu_app_info = {
.common = {
.main_func = prv_main,
// UUID: dec0424c-0625-4878-b1f2-147e57e83688
.uuid = {0xde, 0xc0, 0x42, 0x4c, 0x06, 0x25, 0x48, 0x78,
0xb1, 0xf2, 0x14, 0x7e, 0x57, 0xe8, 0x36, 0x88},
.visibility = ProcessVisibilityHidden
},
.name = "Launcher",
};
return (const PebbleProcessMd *)&s_launcher_menu_app_info;
}
const LauncherDrawState *launcher_app_get_draw_state(void) {
return &s_launcher_app_persisted_data.draw_state;
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "../launcher_app.h"
#include "launcher_menu_layer.h"
#include "applib/graphics/gtypes.h"
#include <stdbool.h>
typedef struct LauncherMenuArgs {
bool reset_scroll;
} LauncherMenuArgs;
typedef struct LauncherDrawState {
GRangeVertical selection_vertical_range;
GColor selection_background_color;
} LauncherDrawState;
const LauncherDrawState *launcher_app_get_draw_state(void);

View file

@ -0,0 +1,93 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance.h"
#include "launcher_menu_layer.h"
#include "applib/app_glance.h"
#include "applib/ui/kino/kino_reel_custom.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "system/passert.h"
#include "util/string.h"
void launcher_app_glance_init(LauncherAppGlance *glance, const Uuid *uuid, KinoReel *impl,
bool should_consider_slices,
const LauncherAppGlanceHandlers *handlers) {
if (!glance || !uuid) {
return;
}
*glance = (LauncherAppGlance) {
.uuid = *uuid,
.reel = impl,
.should_consider_slices = should_consider_slices,
};
if (handlers) {
glance->handlers = *handlers;
}
launcher_app_glance_update_current_slice(glance);
}
void launcher_app_glance_update_current_slice(LauncherAppGlance *glance) {
if (!glance || !glance->should_consider_slices) {
return;
}
const Uuid *uuid = &glance->uuid;
AppGlanceSliceInternal current_slice = {};
// If there's no current slice, this function won't modify the zeroed-out current_slice, so we
// can safely set the glance's current slice to current_slice either way
app_glance_service_get_current_slice(uuid, &current_slice);
glance->current_slice = current_slice;
if (glance->handlers.current_slice_updated) {
glance->handlers.current_slice_updated(glance);
}
launcher_app_glance_service_notify_glance_changed(glance->service);
}
void launcher_app_glance_draw(GContext *ctx, const GRect *frame, LauncherAppGlance *glance,
bool is_highlighted) {
if (!glance || !frame || !ctx) {
return;
}
glance->size = frame->size;
glance->is_highlighted = is_highlighted;
kino_reel_draw(glance->reel, ctx, frame->origin);
}
void launcher_app_glance_notify_service_glance_changed(LauncherAppGlance *glance) {
if (!glance) {
return;
}
launcher_app_glance_service_notify_glance_changed(glance->service);
}
void launcher_app_glance_destroy(LauncherAppGlance *glance) {
if (!glance) {
return;
}
kino_reel_destroy(glance->reel);
app_free(glance);
}

View file

@ -0,0 +1,86 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance_service.h"
#include "applib/ui/kino/kino_reel.h"
#include "services/normal/app_glances/app_glance_service.h"
#include "util/uuid.h"
//! Forward declaration
typedef struct LauncherAppGlance LauncherAppGlance;
//! Called when a launcher app glance's current slice has been updated. The glance will
//! automatically be redrawn after this function is called.
//! @param The glance whose current slice has been updated
typedef void (*LauncherAppGlanceCurrentSliceUpdated)(LauncherAppGlance *glance);
typedef struct LauncherAppGlanceHandlers {
LauncherAppGlanceCurrentSliceUpdated current_slice_updated;
} LauncherAppGlanceHandlers;
struct LauncherAppGlance {
//! The UUID of the app the launcher app glance represents
Uuid uuid;
//! The reel that implements how the launcher app glance should be drawn
KinoReel *reel;
//! Size of the area in which the launcher app glance expects to draw itself
GSize size;
//! Whether or not the launcher app glance is currently highlighted
bool is_highlighted;
//! Whether or not the launcher app glance should consider slices
bool should_consider_slices;
//! The current slice that should be drawn in the launcher app glance
AppGlanceSliceInternal current_slice;
//! The launcher app glance service that created the glance; used by the glance to notify the
//! service that the glance needs to be redrawn
LauncherAppGlanceService *service;
//! Callback handlers for the launcher app glance
LauncherAppGlanceHandlers handlers;
};
//! Initialize a launcher app glance.
//! @param glance The glance to initialize
//! @param uuid The UUID of the app
//! @param impl The KinoReel implementation for the glance
//! @param should_consider_slices Whether or not the glance should consider slices
//! @param handlers Optional handlers to use with the glance
void launcher_app_glance_init(LauncherAppGlance *glance, const Uuid *uuid, KinoReel *impl,
bool should_consider_slices,
const LauncherAppGlanceHandlers *handlers);
//! Update the current slice of the launcher app glance as well as the icon if the slice needs to
//! change it.
//! @param glance The glance for which to update the current slice
void launcher_app_glance_update_current_slice(LauncherAppGlance *glance);
//! Draw the provided launcher app glance.
//! @param ctx The graphics context to use when drawing the glance
//! @param frame The frame in which to draw the glance
//! @param glance The glance to draw
//! @param is_highlighted Whether or not the glance should be drawn highlighted
void launcher_app_glance_draw(GContext *ctx, const GRect *frame, LauncherAppGlance *glance,
bool is_highlighted);
//! Notify the launcher app glance's service that its content has changed.
//! @param glance The glance that has changed
void launcher_app_glance_notify_service_glance_changed(LauncherAppGlance *glance);
//! Destroy the provided launcher app glance.
//! @param glance The glance to destroy
void launcher_app_glance_destroy(LauncherAppGlance *glance);

View file

@ -0,0 +1,197 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_alarms.h"
#include "launcher_app_glance_structured.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/common/clock.h"
#include "services/normal/alarms/alarm.h"
#include "services/normal/timeline/attribute.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
typedef struct LauncherAppGlanceAlarms {
char title[APP_NAME_SIZE_BYTES];
char subtitle[ATTRIBUTE_APP_GLANCE_SUBTITLE_MAX_LEN];
KinoReel *icon;
uint32_t icon_resource_id;
uint32_t default_icon_resource_id;
EventServiceInfo alarm_clock_event_info;
} LauncherAppGlanceAlarms;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceAlarms *alarms_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(alarms_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceAlarms *alarms_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(alarms_glance, title, NULL);
}
static void prv_alarms_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceAlarms *alarms_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (alarms_glance) {
strncpy(buffer, alarms_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_alarms_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceAlarms *alarms_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (alarms_glance) {
event_service_client_unsubscribe(&alarms_glance->alarm_clock_event_info);
kino_reel_destroy(alarms_glance->icon);
}
app_free(alarms_glance);
}
static void prv_set_glance_icon(LauncherAppGlanceAlarms *alarms_glance,
uint32_t new_icon_resource_id) {
if (alarms_glance->icon_resource_id == new_icon_resource_id) {
// Nothing to do, bail out
return;
}
// Destroy the existing icon
kino_reel_destroy(alarms_glance->icon);
// Set the new icon and record its resource ID
alarms_glance->icon = kino_reel_create_with_resource(new_icon_resource_id);
PBL_ASSERTN(alarms_glance->icon);
alarms_glance->icon_resource_id = new_icon_resource_id;
}
//! If alarm is for today, alarm text should look like "5:43 PM" (12 hr) or "17:43" (24 hr)
//! If alarm is not for today, text should look like "Fri, 11:30 PM" (12 hr) or "Fri, 23:30" (24 hr)
//! If no alarms are set, the alarm text should be the empty string ""
static void prv_update_glance_for_next_alarm(LauncherAppGlanceAlarms *alarms_glance) {
// Start by assuming we'll set the default icon
uint32_t new_icon_resource_id = alarms_glance->default_icon_resource_id;
time_t alarm_time_epoch;
if (!alarm_get_next_enabled_alarm(&alarm_time_epoch)) {
// Clear the alarm text if there are no alarms set
alarms_glance->subtitle[0] = '\0';
} else {
// If the next alarm is smart, use the smart alarm icon
if (alarm_is_next_enabled_alarm_smart()) {
// TODO PBL-39113: Replace this placeholder with a better smart alarm icon for the glance
new_icon_resource_id = RESOURCE_ID_SMART_ALARM_TINY;
}
char time_buffer[TIME_STRING_REQUIRED_LENGTH] = {};
clock_copy_time_string_timestamp(time_buffer, sizeof(time_buffer), alarm_time_epoch);
// Determine if the alarm is for today
const time_t current_time = rtc_get_time();
const time_t today_midnight = time_util_get_midnight_of(current_time);
const time_t alarm_midnight = time_util_get_midnight_of(alarm_time_epoch);
const bool is_alarm_for_today = (alarm_midnight == today_midnight);
const size_t alarm_subtitle_size = sizeof(alarms_glance->subtitle);
// Only show the day of the week if the alarm is not for today
if (!is_alarm_for_today) {
// Get a string for the abbreviated day of the week in the user's locale
char day_buffer[TIME_STRING_REQUIRED_LENGTH] = {};
struct tm alarm_time;
localtime_r(&alarm_time_epoch, &alarm_time);
strftime(day_buffer, sizeof(day_buffer), "%a", &alarm_time);
snprintf(alarms_glance->subtitle, alarm_subtitle_size, "%s, %s", day_buffer, time_buffer);
} else {
strncpy(alarms_glance->subtitle, time_buffer, alarm_subtitle_size);
alarms_glance->subtitle[alarm_subtitle_size - 1] = '\0';
}
}
// Update the icon
prv_set_glance_icon(alarms_glance, new_icon_resource_id);
}
static void prv_alarm_clock_event_handler(UNUSED PebbleEvent *event, void *context) {
LauncherAppGlanceStructured *structured_glance = context;
LauncherAppGlanceAlarms *alarms_glance =
launcher_app_glance_structured_get_data(structured_glance);
prv_update_glance_for_next_alarm(alarms_glance);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static const LauncherAppGlanceStructuredImpl s_alarms_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_alarms_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceAlarms *alarms_glance = app_zalloc_check(sizeof(*alarms_glance));
// Copy the name of the Alarms app as the title
const size_t title_size = sizeof(alarms_glance->title);
strncpy(alarms_glance->title, node->name, title_size);
alarms_glance->title[title_size - 1] = '\0';
// Save the default app icon resource ID
alarms_glance->default_icon_resource_id = node->icon_resource_id;
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_alarms_structured_glance_impl,
should_consider_slices, alarms_glance);
PBL_ASSERTN(structured_glance);
// Get the first state of the glance
prv_update_glance_for_next_alarm(alarms_glance);
// Subscribe to alarm clock events for updating the glance
alarms_glance->alarm_clock_event_info = (EventServiceInfo) {
.type = PEBBLE_ALARM_CLOCK_EVENT,
.handler = prv_alarm_clock_event_handler,
.context = structured_glance,
};
event_service_client_subscribe(&alarms_glance->alarm_clock_event_info);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_alarms_create(const AppMenuNode *node);

View file

@ -0,0 +1,395 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_generic.h"
#include "launcher_app_glance_structured.h"
#include "applib/app_glance.h"
#include "applib/app_timer.h"
#include "applib/template_string.h"
#include "applib/ui/kino/kino_reel.h"
#include "apps/system_apps/timeline/text_node.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "process_management/pebble_process_info.h"
#include "services/normal/timeline/timeline_resources.h"
#include "system/passert.h"
#include "util/string.h"
#include "util/struct.h"
#define APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MAJOR (PROCESS_INFO_FIRST_4X_SDK_VERSION_MAJOR)
#define APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MINOR (PROCESS_INFO_FIRST_4X_SDK_VERSION_MINOR)
typedef struct LauncherAppGlanceGeneric {
//! The title that will be displayed
char title_buffer[APP_NAME_SIZE_BYTES];
//! The icon that will be displayed
KinoReel *displayed_icon;
//! The resource info of the displayed icon
AppResourceInfo displayed_icon_resource_info;
//! The resource info of the default app icon
AppResourceInfo default_icon_resource_info;
//! Fallback icon to use if other icons aren't available; owned by client
const KinoReel *fallback_icon;
//! The resource ID of the fallback icon; used for comparisons
uint32_t fallback_icon_resource_id;
//! App timer used for re-evaluating the current slice's subtitle template string
AppTimer *slice_subtitle_template_string_reeval_timer;
//! UTC timestamp of when the current slice's subtitle template string must be re-evaluated
//! A zero value indicates that there is no need to re-evaluate the subtitle template string
time_t next_slice_subtitle_template_string_reeval_time;
//! Whether to use the legacy 28x28 icon size limit
bool use_legacy_28x28_icon_size_limit;
} LauncherAppGlanceGeneric;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(generic_glance, displayed_icon, NULL);
}
static void prv_generic_glance_destroy_displayed_icon(LauncherAppGlanceGeneric *generic_glance) {
// Only delete the displayed icon if it doesn't match the fallback icon because we don't own it
if (generic_glance && (generic_glance->displayed_icon != generic_glance->fallback_icon)) {
kino_reel_destroy(generic_glance->displayed_icon);
}
}
static KinoReel *prv_create_glance_icon(const AppResourceInfo *res_info,
bool legacy_icon_size_limit) {
if (!res_info) {
return NULL;
}
KinoReel *icon = kino_reel_create_with_resource_system(res_info->res_app_num, res_info->res_id);
if (!icon) {
return NULL;
}
const GSize size = kino_reel_get_size(icon);
// Not const to deal with horrifying GCC bug
GSize max_size = legacy_icon_size_limit ? LAUNCHER_APP_GLANCE_STRUCTURED_ICON_LEGACY_MAX_SIZE
: LAUNCHER_APP_GLANCE_STRUCTURED_ICON_MAX_SIZE;
if ((size.w > max_size.w) ||
(size.h > max_size.h)) {
// The icon is too big
kino_reel_destroy(icon);
return NULL;
}
return icon;
}
static bool prv_app_resource_info_equal(const AppResourceInfo *a, const AppResourceInfo *b) {
PBL_ASSERTN(a && b);
return ((a == b) || ((a->res_id == b->res_id) && (a->res_app_num == b->res_app_num)));
}
static void prv_generic_glance_set_icon(LauncherAppGlanceGeneric *generic_glance,
const AppResourceInfo *res_info) {
if (!generic_glance || !res_info) {
return;
}
const bool is_requested_resource_the_default_icon =
((res_info->res_app_num == generic_glance->default_icon_resource_info.res_app_num) &&
((res_info->res_id == APP_GLANCE_SLICE_DEFAULT_ICON) ||
(res_info->res_id == generic_glance->default_icon_resource_info.res_id)));
const bool does_default_icon_need_to_be_loaded =
(is_requested_resource_the_default_icon &&
!prv_app_resource_info_equal(&generic_glance->displayed_icon_resource_info,
&generic_glance->default_icon_resource_info));
const bool is_icon_stale =
(!prv_app_resource_info_equal(&generic_glance->displayed_icon_resource_info, res_info));
if (generic_glance->displayed_icon &&
!does_default_icon_need_to_be_loaded &&
!is_icon_stale) {
// Nothing to do, bail out
return;
}
// Destroy the currently displayed icon
prv_generic_glance_destroy_displayed_icon(generic_glance);
AppResourceInfo res_info_to_load = *res_info;
// Set the resource info to the real default icon resource info if the default icon was requested
if (is_requested_resource_the_default_icon) {
res_info_to_load = generic_glance->default_icon_resource_info;
}
const bool legacy_icon_size_limit = generic_glance->use_legacy_28x28_icon_size_limit;
// Try loading the requested icon
generic_glance->displayed_icon = prv_create_glance_icon(&res_info_to_load,
legacy_icon_size_limit);
if (!generic_glance->displayed_icon) {
// Try again with the app's default icon if we didn't just try it
if (!prv_app_resource_info_equal(&res_info_to_load,
&generic_glance->default_icon_resource_info)) {
res_info_to_load = generic_glance->default_icon_resource_info;
generic_glance->displayed_icon = prv_create_glance_icon(&res_info_to_load,
legacy_icon_size_limit);
}
// If we don't have a valid icon at this point, use the fallback icon (casting to non-const so
// we can use it)
if (!generic_glance->displayed_icon && generic_glance->fallback_icon) {
// Note that this (reasonably) assumes that the system fallback icon is a system icon
res_info_to_load = (AppResourceInfo) {
.res_app_num = SYSTEM_APP,
.res_id = generic_glance->fallback_icon_resource_id,
};
generic_glance->displayed_icon = (KinoReel *)generic_glance->fallback_icon;
}
}
// We require that we have some sort of icon at this point
PBL_ASSERTN(generic_glance->displayed_icon);
// Update our recording of the resource info of the displayed icon
generic_glance->displayed_icon_resource_info = res_info_to_load;
}
static void prv_cancel_subtitle_reeval_timer(LauncherAppGlanceGeneric *generic_glance) {
if (!generic_glance) {
return;
}
if (generic_glance->slice_subtitle_template_string_reeval_timer) {
app_timer_cancel(generic_glance->slice_subtitle_template_string_reeval_timer);
generic_glance->slice_subtitle_template_string_reeval_timer = NULL;
}
// Set the next re-evaluation time to "never"
generic_glance->next_slice_subtitle_template_string_reeval_time = 0;
}
static void prv_subtitle_reeval_timer_cb(void *data) {
LauncherAppGlanceStructured *structured_glance = data;
PBL_ASSERTN(structured_glance);
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
// Reset the timer
generic_glance->slice_subtitle_template_string_reeval_timer = NULL;
prv_cancel_subtitle_reeval_timer(generic_glance);
// Notify the service that the glance changed
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static void prv_update_subtitle_template_string_reeval_timer_if_necessary(
LauncherAppGlanceStructured *structured_glance, time_t new_reeval_time) {
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
const time_t existing_reeval_time =
generic_glance->next_slice_subtitle_template_string_reeval_time;
// Bail out if the new re-evaluation time is not earlier than the existing one we have
if ((new_reeval_time == 0) ||
((existing_reeval_time != 0) && (new_reeval_time >= existing_reeval_time))) {
return;
}
const int time_until_next_reeval = new_reeval_time - rtc_get_time();
// On the off chance that we missed the reeval, immediately call the timer callback
if (time_until_next_reeval <= 0) {
prv_subtitle_reeval_timer_cb(structured_glance);
return;
}
const uint64_t time_until_next_reeval_ms = (uint64_t)time_until_next_reeval * MS_PER_SECOND;
if (time_until_next_reeval_ms > UINT32_MAX) {
// Next reeval time is so far in the future that its offset in milliseconds from the
// current time would overflow the argument to AppTimer, so just ignore this reeval because it's
// not worth setting a timer for it
return;
}
prv_cancel_subtitle_reeval_timer(generic_glance);
generic_glance->slice_subtitle_template_string_reeval_timer =
app_timer_register((uint32_t)time_until_next_reeval_ms, prv_subtitle_reeval_timer_cb,
structured_glance);
generic_glance->next_slice_subtitle_template_string_reeval_time = new_reeval_time;
}
static void prv_current_slice_updated(LauncherAppGlance *glance) {
// Ignore slices that aren't of the IconAndSubtitle type beyond this point for now
if (glance->current_slice.type != AppGlanceSliceType_IconAndSubtitle) {
return;
}
LauncherAppGlanceStructured *structured_glance = (LauncherAppGlanceStructured *)glance;
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
const TimelineResourceId timeline_res_id =
(TimelineResourceId)glance->current_slice.icon_and_subtitle.icon_resource_id;
// Initialize the resource info to be the default icon
AppResourceInfo resource_info = generic_glance->default_icon_resource_info;
// Override it if we have a valid timeline resource ID from the new app glance slice
if (timeline_res_id != APP_GLANCE_SLICE_DEFAULT_ICON) {
// NOTE: This variant of the timeline_resources_get_id() function is safe to call here with
// respect to the app supporting published resources because we only consider slices if the
// glance is for a system app (where it doesn't matter) or for apps that were compiled with
// an SDK that supports app glances (which is newer than the first SDK that supported published
// resources as proved by the following asserts)
_Static_assert((APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MAJOR >
TIMELINE_RESOURCE_PBW_SUPPORT_FIRST_SDK_VERSION_MAJOR) ||
((APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MAJOR ==
TIMELINE_RESOURCE_PBW_SUPPORT_FIRST_SDK_VERSION_MAJOR) &&
(APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MINOR >=
TIMELINE_RESOURCE_PBW_SUPPORT_FIRST_SDK_VERSION_MINOR)),
"App glance min supported SDK version must be equal to or newer than first "
"timeline/published resource PBW supported SDK version");
timeline_resources_get_id_system(timeline_res_id, LAUNCHER_APP_GLANCE_GENERIC_ICON_SIZE_TYPE,
resource_info.res_app_num, &resource_info);
}
prv_generic_glance_set_icon(generic_glance, &resource_info);
prv_cancel_subtitle_reeval_timer(generic_glance);
// The glance will automatically be redrawn after this function is called (which will also update
// the glance's state regarding its subtitle template string), so no need to mark it as dirty
// (see launcher_app_glance_update_current_slice())
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(generic_glance, title_buffer, NULL);
}
static void prv_generic_glance_dynamic_text_node_update(UNUSED GContext *ctx,
UNUSED GTextNode *node,
UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config,
UNUSED bool render, char *buffer,
size_t buffer_size, void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
if (!structured_glance) {
return;
} else if (structured_glance->glance.current_slice.type != AppGlanceSliceType_IconAndSubtitle) {
PBL_LOG(LOG_LEVEL_WARNING, "Generic glance doesn't know how to handle slice type %d",
structured_glance->glance.current_slice.type);
return;
}
// Evaluate the subtitle as a template string
const char *subtitle_template_string =
structured_glance->glance.current_slice.icon_and_subtitle.template_string;
TemplateStringEvalConditions template_string_reeval_conditions = {0};
const TemplateStringVars template_string_vars = (TemplateStringVars) {
.current_time = rtc_get_time(),
};
TemplateStringError template_string_error = {0};
template_string_evaluate(subtitle_template_string, buffer, buffer_size,
&template_string_reeval_conditions, &template_string_vars,
&template_string_error);
if (template_string_error.status != TemplateStringErrorStatus_Success) {
// Zero out the buffer and return
buffer[0] = '\0';
PBL_LOG(LOG_LEVEL_WARNING, "Error at index %zu in evaluating template string: %s",
template_string_error.index_in_string, subtitle_template_string);
return;
}
// Update the timer for re-evaluating the template string, if necessary
prv_update_subtitle_template_string_reeval_timer_if_necessary(
structured_glance, template_string_reeval_conditions.eval_time);
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_generic_glance_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceGeneric *generic_glance =
launcher_app_glance_structured_get_data(structured_glance);
prv_cancel_subtitle_reeval_timer(generic_glance);
prv_generic_glance_destroy_displayed_icon(generic_glance);
app_free(generic_glance);
}
static const LauncherAppGlanceStructuredImpl s_generic_structured_glance_impl = {
.base_handlers.current_slice_updated = prv_current_slice_updated,
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_generic_create(const AppMenuNode *node,
const KinoReel *fallback_icon,
uint32_t fallback_icon_resource_id) {
if (!node) {
return NULL;
}
LauncherAppGlanceGeneric *generic_glance = app_zalloc_check(sizeof(*generic_glance));
const size_t title_buffer_size = sizeof(generic_glance->title_buffer);
strncpy(generic_glance->title_buffer, node->name, title_buffer_size);
generic_glance->title_buffer[title_buffer_size - 1] = '\0';
generic_glance->default_icon_resource_info = (AppResourceInfo) {
.res_app_num = node->app_num,
.res_id = node->icon_resource_id,
};
generic_glance->fallback_icon = fallback_icon;
generic_glance->fallback_icon_resource_id = fallback_icon_resource_id;
const Version app_glance_min_supported_sdk_version = (Version) {
.major = APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MAJOR,
.minor = APP_GLANCE_MIN_SUPPORTED_SDK_VERSION_MINOR,
};
const bool app_glances_supported =
(version_compare(node->sdk_version, app_glance_min_supported_sdk_version) >= 0);
generic_glance->use_legacy_28x28_icon_size_limit = !app_glances_supported;
// Our unit tests rely on system app icons for testing generic glances which means
// the != SYSTEM_APP condition can't be satisfied easily, so just skip this part for unit tests
#if !UNITTEST
// Only consider slices for non-system apps that were compiled with an SDK that supports glances
const bool should_consider_slices = (node->app_num != SYSTEM_APP) &&
app_glances_supported;
#else
const bool should_consider_slices = true;
#endif
prv_generic_glance_set_icon(generic_glance, &generic_glance->default_icon_resource_info);
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_generic_structured_glance_impl,
should_consider_slices, generic_glance);
if (structured_glance) {
if (generic_glance->use_legacy_28x28_icon_size_limit) {
launcher_app_glance_structured_set_icon_max_size(structured_glance,
LAUNCHER_APP_GLANCE_STRUCTURED_ICON_LEGACY_MAX_SIZE);
}
return &structured_glance->glance;
} else {
return NULL;
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "applib/ui/kino/kino_reel.h"
#include "process_management/app_menu_data_source.h"
#include "services/normal/timeline/timeline_resources.h"
#define LAUNCHER_APP_GLANCE_GENERIC_ICON_SIZE_TYPE (TimelineResourceSizeTiny)
//! Create a generic launcher app glance for the provided app menu node.
//! @param node The node that the new generic glance should represent
//! @param fallback_icon A long-lived fallback icon to use if no other icons are available; will
//! not be destroyed when the generic glance is destroyed
//! @param fallback_icon_resource_id The resource ID of the fallback icon
LauncherAppGlance *launcher_app_glance_generic_create(const AppMenuNode *node,
const KinoReel *fallback_icon,
uint32_t fallback_icon_resource_id);

View file

@ -0,0 +1,222 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_music.h"
#include "launcher_app_glance_structured.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/normal/music.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
// We need enough space for the track artist and title (so 2 * MUSIC_BUFFER_LENGTH from music.h),
// the delimiter string " - " (3), and 1 for the null terminator
#define TRACK_TEXT_BUFFER_SIZE ((MUSIC_BUFFER_LENGTH * 2) + 3 + 1)
typedef struct LauncherAppGlanceMusic {
char title[APP_NAME_SIZE_BYTES];
char subtitle[TRACK_TEXT_BUFFER_SIZE];
KinoReel *icon;
uint32_t icon_resource_id;
uint32_t default_icon_resource_id;
EventServiceInfo music_event_info;
} LauncherAppGlanceMusic;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceMusic *music_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(music_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceMusic *music_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(music_glance, title, NULL);
}
static void prv_music_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceMusic *music_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (music_glance) {
strncpy(buffer, music_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_music_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceMusic *music_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (music_glance) {
event_service_client_unsubscribe(&music_glance->music_event_info);
kino_reel_destroy(music_glance->icon);
}
app_free(music_glance);
}
static void prv_set_glance_icon(LauncherAppGlanceMusic *music_glance,
uint32_t new_icon_resource_id) {
if (music_glance->icon_resource_id == new_icon_resource_id) {
// Nothing to do, bail out
return;
}
// Destroy the existing icon
kino_reel_destroy(music_glance->icon);
// Set the new icon and record its resource ID
// TODO PBL-38539: Switch from using a regular resource ID to using a TimelineResourceId
music_glance->icon = kino_reel_create_with_resource(new_icon_resource_id);
PBL_ASSERTN(music_glance->icon);
music_glance->icon_resource_id = new_icon_resource_id;
}
static bool prv_should_display_music_state(MusicPlayState play_state,
uint32_t last_updated_time_elapsed_ms) {
const uint32_t music_last_updated_display_threshold_ms = 30 * MS_PER_SECOND * SECONDS_PER_MINUTE;
switch (play_state) {
case MusicPlayStatePlaying:
case MusicPlayStateForwarding:
case MusicPlayStateRewinding:
return true;
case MusicPlayStatePaused:
// We won't display the music state if the music is paused and it hasn't changed in a while
return (last_updated_time_elapsed_ms < music_last_updated_display_threshold_ms);
case MusicPlayStateUnknown:
case MusicPlayStateInvalid:
return false;
}
WTF;
}
static void prv_update_glance_for_music_state(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceMusic *music_glance =
launcher_app_glance_structured_get_data(structured_glance);
PBL_ASSERTN(music_glance);
// Zero out the glance's subtitle buffer
const size_t music_glance_subtitle_size = sizeof(music_glance->subtitle);
memset(music_glance->subtitle, 0, music_glance_subtitle_size);
// Default to showing the default icon
uint32_t new_icon_resource_id = music_glance->default_icon_resource_id;
// Determine if we should display the current music state
const MusicPlayState play_state = music_get_playback_state();
const uint32_t last_updated_time_elapsed_ms = music_get_ms_since_pos_last_updated();
const bool should_display_music_state =
prv_should_display_music_state(play_state, last_updated_time_elapsed_ms);
if (should_display_music_state) {
// Get the artist and title strings for the music playing or paused
char artist_buffer[MUSIC_BUFFER_LENGTH] = {};
char title_buffer[MUSIC_BUFFER_LENGTH] = {};
music_get_now_playing(title_buffer, artist_buffer, NULL /* album_buffer */);
// Only populate the glance with music info if we have both an artist string and a title string
if (!IS_EMPTY_STRING(artist_buffer) && !IS_EMPTY_STRING(title_buffer)) {
// Use the strings to fill the subtitle buffer
snprintf(music_glance->subtitle, music_glance_subtitle_size, "%s - %s", artist_buffer,
title_buffer);
// Choose the icon we should display; note that we'll use the default icon we set above if we
// don't have an icon for the current play state
if (play_state == MusicPlayStatePlaying) {
new_icon_resource_id = RESOURCE_ID_MUSIC_APP_GLANCE_PLAY;
} else if (play_state == MusicPlayStatePaused) {
new_icon_resource_id = RESOURCE_ID_MUSIC_APP_GLANCE_PAUSE;
}
}
}
// Update the glance icon
prv_set_glance_icon(music_glance, new_icon_resource_id);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static void prv_music_event_handler(PebbleEvent *event, void *context) {
switch (event->media.type) {
case PebbleMediaEventTypeNowPlayingChanged:
case PebbleMediaEventTypePlaybackStateChanged:
case PebbleMediaEventTypeServerConnected:
case PebbleMediaEventTypeServerDisconnected:
prv_update_glance_for_music_state(context);
return;
case PebbleMediaEventTypeVolumeChanged:
case PebbleMediaEventTypeTrackPosChanged:
return;
}
WTF;
}
static const LauncherAppGlanceStructuredImpl s_music_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_music_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceMusic *music_glance = app_zalloc_check(sizeof(*music_glance));
// Copy the name of the Music app as the title
const size_t title_size = sizeof(music_glance->title);
strncpy(music_glance->title, node->name, title_size);
music_glance->title[title_size - 1] = '\0';
// Save the default icon resource ID for the Music app
music_glance->default_icon_resource_id = node->icon_resource_id;
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_music_structured_glance_impl,
should_consider_slices, music_glance);
PBL_ASSERTN(structured_glance);
// Get the first state of the glance
prv_update_glance_for_music_state(structured_glance);
// Subscribe to music events for updating the glance
music_glance->music_event_info = (EventServiceInfo) {
.type = PEBBLE_MEDIA_EVENT,
.handler = prv_music_event_handler,
.context = structured_glance,
};
event_service_client_subscribe(&music_glance->music_event_info);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_music_create(const AppMenuNode *node);

View file

@ -0,0 +1,185 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_notifications.h"
#include "launcher_app_glance_structured.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "services/normal/notifications/notification_storage.h"
#include "services/normal/timeline/attribute.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
typedef struct LauncherAppGlanceNotifications {
char title[APP_NAME_SIZE_BYTES];
char subtitle[ATTRIBUTE_APP_GLANCE_SUBTITLE_MAX_LEN];
KinoReel *icon;
EventServiceInfo notification_event_info;
} LauncherAppGlanceNotifications;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceNotifications *notifications_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(notifications_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceNotifications *notifications_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(notifications_glance, title, NULL);
}
static void prv_notifications_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceNotifications *notifications_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (notifications_glance) {
strncpy(buffer, notifications_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_notifications_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceNotifications *notifications_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (notifications_glance) {
event_service_client_unsubscribe(&notifications_glance->notification_event_info);
kino_reel_destroy(notifications_glance->icon);
}
app_free(notifications_glance);
}
static bool prv_notification_iterator_cb(void *data, SerializedTimelineItemHeader *header_id) {
Uuid *last_notification_received_id = data;
// The iterator proceeds from the first notification received to the last notification received,
// so copy the ID of the current notification and then return true so we iterate until the end.
// Thus the last ID we save will be the last notification received.
*last_notification_received_id = header_id->common.id;
return true;
}
static void prv_update_glance_for_last_notification_received(
LauncherAppGlanceNotifications *notifications_glance) {
// Find the ID of the last notification received
Uuid last_notification_received_id;
notification_storage_iterate(prv_notification_iterator_cb, &last_notification_received_id);
TimelineItem notification;
if (!notification_storage_get(&last_notification_received_id, &notification)) {
// We couldn't load the notification for some reason; just bail out with the subtitle cleared
notifications_glance->subtitle[0] = '\0';
return;
}
const char *title = attribute_get_string(&notification.attr_list, AttributeIdTitle, "");
const char *subtitle = attribute_get_string(&notification.attr_list, AttributeIdSubtitle, "");
const char *body = attribute_get_string(&notification.attr_list, AttributeIdBody, "");
// Determine which string we should use in the glance subtitle
const char *string_to_use_in_glance_subtitle = "";
if (!IS_EMPTY_STRING(title)) {
string_to_use_in_glance_subtitle = title;
} else if (!IS_EMPTY_STRING(subtitle)) {
// Fallback to the subtitle
string_to_use_in_glance_subtitle = subtitle;
} else {
// Fallback to the body
string_to_use_in_glance_subtitle = body;
}
// Copy the string to the glance
const size_t glance_subtitle_size = sizeof(notifications_glance->subtitle);
strncpy(notifications_glance->subtitle, string_to_use_in_glance_subtitle, glance_subtitle_size);
notifications_glance->subtitle[glance_subtitle_size - 1] = '\0';
}
static void prv_notification_event_handler(PebbleEvent *event, void *context) {
LauncherAppGlanceStructured *structured_glance = context;
LauncherAppGlanceNotifications *notifications_glance =
launcher_app_glance_structured_get_data(structured_glance);
switch (event->sys_notification.type) {
case NotificationAdded:
case NotificationRemoved:
prv_update_glance_for_last_notification_received(notifications_glance);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
return;
case NotificationActedUpon:
case NotificationActionResult:
return;
}
WTF;
}
static const LauncherAppGlanceStructuredImpl s_notifications_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_notifications_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceNotifications *notifications_glance =
app_zalloc_check(sizeof(*notifications_glance));
// Copy the name of the Notifications app as the title
const size_t title_size = sizeof(notifications_glance->title);
strncpy(notifications_glance->title, node->name, title_size);
notifications_glance->title[title_size - 1] = '\0';
// Create the icon for the Notifications app
notifications_glance->icon = kino_reel_create_with_resource_system(node->app_num,
node->icon_resource_id);
PBL_ASSERTN(notifications_glance->icon);
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_notifications_structured_glance_impl,
should_consider_slices, notifications_glance);
PBL_ASSERTN(structured_glance);
// Get the first state of the glance
prv_update_glance_for_last_notification_received(notifications_glance);
// Subscribe to notification events for updating the glance
notifications_glance->notification_event_info = (EventServiceInfo) {
.type = PEBBLE_SYS_NOTIFICATION_EVENT,
.handler = prv_notification_event_handler,
.context = structured_glance,
};
event_service_client_subscribe(&notifications_glance->notification_event_info);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_notifications_create(const AppMenuNode *node);

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance.h"
#include "applib/ui/kino/kino_reel_custom.h"
#include "system/passert.h"
#include "util/struct.h"
GSize launcher_app_glance_get_size_for_reel(KinoReel *reel) {
PBL_ASSERTN(reel->impl && (reel->impl->reel_type == KinoReelTypeCustom));
LauncherAppGlance *glance = kino_reel_custom_get_data(reel);
return NULL_SAFE_FIELD_ACCESS(glance, size, GSizeZero);
}

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "applib/ui/kino/kino_reel.h"
//! Get the size of the provided reel that implements how a launcher app glance should be drawn.
//! @param reel The reel that implements how a glance should be drawn
//! @return The size of the reel
GSize launcher_app_glance_get_size_for_reel(KinoReel *reel);

View file

@ -0,0 +1,480 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_service.h"
#include "launcher_app_glance.h"
#include "launcher_app_glance_alarms.h"
#include "launcher_app_glance_generic.h"
#include "launcher_app_glance_music.h"
#include "launcher_app_glance_notifications.h"
#include "launcher_app_glance_settings.h"
#include "launcher_app_glance_watchfaces.h"
#include "launcher_app_glance_weather.h"
#include "launcher_app_glance_workout.h"
#include "launcher_menu_layer.h"
#include "launcher_menu_layer_private.h"
#include "applib/app_glance.h"
#include "applib/ui/kino/kino_reel.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/size.h"
#include "util/struct.h"
#include "util/uuid.h"
//! Cache twice the number of glances we'll show simultaneously in the launcher
#define LAUNCHER_APP_GLANCE_SERVICE_CACHE_NUM_ENTRIES (2 * LAUNCHER_MENU_LAYER_NUM_VISIBLE_ROWS)
typedef struct LauncherAppGlanceCacheEntry {
ListNode node;
LauncherAppGlance *glance;
} LauncherAppGlanceCacheEntry;
//////////////////////////////////////
// KinoPlayer callbacks
static void prv_glance_reel_player_frame_did_change_cb(KinoPlayer *player, void *context) {
LauncherAppGlanceService *service = context;
launcher_app_glance_service_notify_glance_changed(service);
}
///////////////////////////
// Slice expiration timer
static void prv_slice_expiration_timer_cb(void *data);
static void prv_reset_slice_expiration_timer(LauncherAppGlanceService *service) {
if (!service) {
return;
}
if (service->slice_expiration_timer) {
app_timer_cancel(service->slice_expiration_timer);
service->slice_expiration_timer = NULL;
}
// Set the next slice expiration time to "never"
service->next_slice_expiration_time = APP_GLANCE_SLICE_NO_EXPIRATION;
}
static void prv_update_slice_expiration_timer_if_necessary(LauncherAppGlanceService *service,
time_t new_slice_expire_time) {
const time_t next_slice_expiration_time = service->next_slice_expiration_time;
const bool is_new_slice_expire_time_earlier_than_existing_earliest =
(new_slice_expire_time != APP_GLANCE_SLICE_NO_EXPIRATION) &&
((next_slice_expiration_time == APP_GLANCE_SLICE_NO_EXPIRATION) ||
(new_slice_expire_time < next_slice_expiration_time));
if (!is_new_slice_expire_time_earlier_than_existing_earliest) {
return;
}
const int time_until_slice_expires = new_slice_expire_time - rtc_get_time();
// On the off chance that this slice has already expired, immediately call the timer callback
if (time_until_slice_expires <= 0) {
prv_slice_expiration_timer_cb(service);
return;
}
const uint64_t time_until_slice_expires_ms = (uint64_t)time_until_slice_expires * MS_PER_SECOND;
if (time_until_slice_expires_ms > UINT32_MAX) {
// Slice expiration time is so far in the future that its offset in milliseconds from the
// current time would overflow the argument to AppTimer, so just ignore this slice because it's
// not worth setting a timer for it
return;
}
prv_reset_slice_expiration_timer(service);
service->slice_expiration_timer =
app_timer_register((uint32_t)time_until_slice_expires_ms, prv_slice_expiration_timer_cb,
service);
service->next_slice_expiration_time = new_slice_expire_time;
}
static bool prv_glance_cache_slice_expiration_foreach_cb(ListNode *node, void *context) {
LauncherAppGlanceService *service = context;
PBL_ASSERTN(service);
const LauncherAppGlanceCacheEntry *entry = (LauncherAppGlanceCacheEntry *)node;
LauncherAppGlance *glance = entry->glance;
// Update the glance's current slice
launcher_app_glance_update_current_slice(glance);
// If necessary, update the slice expiration timer with the updated current slice
prv_update_slice_expiration_timer_if_necessary(service, glance->current_slice.expiration_time);
// Continue iterating until we've looked at all of the glances in the cache
return true;
}
static void prv_slice_expiration_timer_cb(void *data) {
LauncherAppGlanceService *service = data;
PBL_ASSERTN(service);
// Reset the timer
prv_reset_slice_expiration_timer(service);
// Iterate over the glances in the cache to find the next earliest expiring slice
list_foreach(service->glance_cache, prv_glance_cache_slice_expiration_foreach_cb, service);
}
/////////////////////
// Glance cache
_Static_assert((offsetof(LauncherAppGlanceCacheEntry, node) == 0),
"ListNode is not the first field of LauncherAppGlanceCacheEntry");
static void prv_glance_cache_destroy_entry(LauncherAppGlanceService *service,
LauncherAppGlanceCacheEntry *entry) {
if (!entry) {
return;
}
if (service) {
PBL_ASSERTN(entry->glance);
KinoReel *glance_reel = entry->glance->reel;
// Set the glance reel player's reel to NULL if it belongs to the glance we're going to destroy
KinoPlayer *glance_reel_player = &service->glance_reel_player;
if (glance_reel && (glance_reel == kino_player_get_reel(glance_reel_player))) {
kino_player_set_reel(glance_reel_player, NULL, false /* take_ownership */);
}
}
launcher_app_glance_destroy(entry->glance);
app_free(entry);
}
static bool prv_glance_cache_deinit_foreach_cb(ListNode *node, void *context) {
LauncherAppGlanceService *service = context;
LauncherAppGlanceCacheEntry *entry = (LauncherAppGlanceCacheEntry *)node;
prv_glance_cache_destroy_entry(service, entry);
// Continue iterating to destroy all of the entries
return true;
}
static void prv_glance_cache_deinit(LauncherAppGlanceService *service) {
if (service) {
list_foreach(service->glance_cache, prv_glance_cache_deinit_foreach_cb, service);
service->glance_cache = NULL;
}
}
//! Don't call this directly; it's used by prv_get_glance_for_node() below
static void prv_glance_cache_put(LauncherAppGlanceService *service, const Uuid *uuid,
LauncherAppGlance *glance) {
if (!service || !uuid || !glance) {
return;
}
// If necessary, evict the LRU cache entry
const uint32_t cache_entry_count = list_count(service->glance_cache);
PBL_ASSERTN(cache_entry_count <= LAUNCHER_APP_GLANCE_SERVICE_CACHE_NUM_ENTRIES);
if (cache_entry_count == LAUNCHER_APP_GLANCE_SERVICE_CACHE_NUM_ENTRIES) {
LauncherAppGlanceCacheEntry *cache_entry_to_destroy =
(LauncherAppGlanceCacheEntry *)list_get_tail(service->glance_cache);
list_remove(&cache_entry_to_destroy->node, &service->glance_cache, NULL);
prv_glance_cache_destroy_entry(service, cache_entry_to_destroy);
}
// Initialize a new cache entry, add it to the head of the cache list, and return it
LauncherAppGlanceCacheEntry *new_cache_entry = app_zalloc_check(sizeof(*new_cache_entry));
*new_cache_entry = (LauncherAppGlanceCacheEntry) {
.glance = glance,
};
service->glance_cache = list_insert_before(service->glance_cache, &new_cache_entry->node);
}
static bool prv_glance_cache_entry_find_cb(ListNode *current_node, void *context) {
LauncherAppGlanceCacheEntry *current_entry = (LauncherAppGlanceCacheEntry *)current_node;
Uuid *uuid_to_find = context;
return (current_entry && uuid_equal(&current_entry->glance->uuid, uuid_to_find));
}
static LauncherAppGlance *prv_load_glance_for_node(const AppMenuNode *node,
LauncherAppGlanceService *service) {
typedef struct {
Uuid uuid;
LauncherAppGlance* (*constructor)(const AppMenuNode *);
} SystemAppGlanceFactory;
static const SystemAppGlanceFactory s_system_glance_factories[] = {
{ // Settings
.uuid = {0x07, 0xe0, 0xd9, 0xcb, 0x89, 0x57, 0x4b, 0xf7,
0x9d, 0x42, 0x35, 0xbf, 0x47, 0xca, 0xad, 0xfe},
.constructor = launcher_app_glance_settings_create,
},
{ // Music
.uuid = {0x1f, 0x03, 0x29, 0x3d, 0x47, 0xaf, 0x4f, 0x28,
0xb9, 0x60, 0xf2, 0xb0, 0x2a, 0x6d, 0xd7, 0x57},
.constructor = launcher_app_glance_music_create,
},
{ // Weather
.uuid = {0x61, 0xb2, 0x2b, 0xc8, 0x1e, 0x29, 0x46, 0xd,
0xa2, 0x36, 0x3f, 0xe4, 0x9, 0xa4, 0x39, 0xff},
.constructor = launcher_app_glance_weather_create,
},
{ // Notifications
.uuid = {0xb2, 0xca, 0xe8, 0x18, 0x10, 0xf8, 0x46, 0xdf,
0xad, 0x2b, 0x98, 0xad, 0x22, 0x54, 0xa3, 0xc1},
.constructor = launcher_app_glance_notifications_create,
},
{ // Alarms
.uuid = {0x67, 0xa3, 0x2d, 0x95, 0xef, 0x69, 0x46, 0xd4,
0xa0, 0xb9, 0x85, 0x4c, 0xc6, 0x2f, 0x97, 0xf9},
.constructor = launcher_app_glance_alarms_create,
},
{ // Watchfaces
.uuid = {0x18, 0xe4, 0x43, 0xce, 0x38, 0xfd, 0x47, 0xc8,
0x84, 0xd5, 0x6d, 0x0c, 0x77, 0x5f, 0xbe, 0x55},
.constructor = launcher_app_glance_watchfaces_create,
},
{
// Workout
.uuid = {0xfe, 0xf8, 0x2c, 0x82, 0x71, 0x76, 0x4e, 0x22,
0x88, 0xde, 0x35, 0xa3, 0xfc, 0x18, 0xd4, 0x3f},
.constructor = launcher_app_glance_workout_create,
},
};
LauncherAppGlance *glance = NULL;
// Check if the UUID matches a known system glance
for (unsigned int i = 0; i < ARRAY_LENGTH(s_system_glance_factories); i++) {
const SystemAppGlanceFactory *factory = &s_system_glance_factories[i];
if (uuid_equal(&factory->uuid, &node->uuid)) {
glance = factory->constructor(node);
break;
}
}
// If we haven't loaded a glance yet, try loading a generic glance for the node
if (!glance) {
glance = launcher_app_glance_generic_create(node, service->generic_glance_icon,
service->generic_glance_icon_resource_id);
}
// If we successfully loaded a glance, set its service field
if (glance) {
glance->service = service;
}
return glance;
}
static LauncherAppGlanceCacheEntry *prv_find_glance_entry_in_cache(
LauncherAppGlanceService *service, Uuid *uuid) {
return (LauncherAppGlanceCacheEntry *)list_find(service->glance_cache,
prv_glance_cache_entry_find_cb, uuid);
}
static LauncherAppGlance *prv_find_glance_in_cache(LauncherAppGlanceService *service, Uuid *uuid) {
const LauncherAppGlanceCacheEntry *entry = prv_find_glance_entry_in_cache(service, uuid);
return NULL_SAFE_FIELD_ACCESS(entry, glance, NULL);
}
//! Request a glance for an icon ID from an "MRU linked list" (list sorted by accesses so that
//! most recent accesses are at the head of the list)
static LauncherAppGlance *prv_fetch_from_cache_or_load_glance_for_node(AppMenuNode *node,
LauncherAppGlanceService *service) {
if (!service || !node) {
return NULL;
}
Uuid *uuid = &node->uuid;
LauncherAppGlance *glance = NULL;
// Try to find the requested glance in the cache
LauncherAppGlanceCacheEntry *cache_entry = prv_find_glance_entry_in_cache(service, uuid);
if (cache_entry) {
// Move the found cache entry to the front of the cache list (to mark it as "MRU")
// This makes it easy to remove the "LRU" entry later by simply removing the tail
list_remove(&cache_entry->node, &service->glance_cache, NULL);
service->glance_cache = list_insert_before(service->glance_cache, &cache_entry->node);
glance = cache_entry->glance;
}
// Try to load the glance requested if we didn't find it in the cache
if (!glance) {
glance = prv_load_glance_for_node(node, service);
if (!glance) {
// Just bail out and don't modify the cache if we fail
return NULL;
}
// Add the new glance to the cache
prv_glance_cache_put(service, uuid, glance);
}
// Update the slice expiration timer if the glance's current slice expires soon
prv_update_slice_expiration_timer_if_necessary(service, glance->current_slice.expiration_time);
return glance;
}
static bool prv_should_use_glance_cache_for_app_with_uuid(const Uuid *uuid) {
// Use the glance cache only if the app does not have the system UUID (all zeros)
return !uuid_is_system(uuid);
}
/////////////////////
// Glance events
static void prv_handle_glance_event(PebbleEvent *event, void *context) {
LauncherAppGlanceService *service = context;
PBL_ASSERTN(service);
// Update the current slice of the glance that was changed if the glance is in the cache
LauncherAppGlance *glance_in_cache = prv_find_glance_in_cache(service,
event->app_glance.app_uuid);
if (glance_in_cache) {
launcher_app_glance_update_current_slice(glance_in_cache);
// If necessary, update the slice expiration timer with the updated current slice
prv_update_slice_expiration_timer_if_necessary(service,
glance_in_cache->current_slice.expiration_time);
}
}
/////////////////////
// Public API
void launcher_app_glance_service_draw_glance_for_app_node(LauncherAppGlanceService *service,
GContext *ctx, const GRect *frame,
bool is_highlighted, AppMenuNode *node) {
const bool use_glance_cache = prv_should_use_glance_cache_for_app_with_uuid(&node->uuid);
LauncherAppGlance *glance =
use_glance_cache ? prv_fetch_from_cache_or_load_glance_for_node(node, service) :
prv_load_glance_for_node(node, service);
// Draw the glance in the provided frame
launcher_app_glance_draw(ctx, frame, glance, is_highlighted);
// If we didn't use the glance cache, destroy the glance now
if (!use_glance_cache) {
launcher_app_glance_destroy(glance);
}
}
void launcher_app_glance_service_rewind_current_glance(LauncherAppGlanceService *service) {
if (!service) {
return;
}
kino_player_rewind(&service->glance_reel_player);
}
void launcher_app_glance_service_pause_current_glance(LauncherAppGlanceService *service) {
if (!service) {
return;
}
kino_player_pause(&service->glance_reel_player);
}
void launcher_app_glance_service_play_current_glance(LauncherAppGlanceService *service) {
if (!service) {
return;
}
kino_player_play(&service->glance_reel_player);
}
void launcher_app_glance_service_play_glance_for_app_node(LauncherAppGlanceService *service,
AppMenuNode *node) {
if (!service || !node) {
return;
}
KinoPlayer *glance_reel_player = &service->glance_reel_player;
// Rewind the player for any previously played glance
kino_player_rewind(glance_reel_player);
const bool use_glance_cache = prv_should_use_glance_cache_for_app_with_uuid(&node->uuid);
if (!use_glance_cache) {
// Don't play glances that we don't store in the cache since they don't live long enough
// to advance frames
return;
}
LauncherAppGlance *glance = prv_fetch_from_cache_or_load_glance_for_node(node, service);
PBL_ASSERTN(glance);
kino_player_set_reel(glance_reel_player, glance->reel, false /* take_ownership */);
kino_player_play(glance_reel_player);
}
void launcher_app_glance_service_notify_glance_changed(LauncherAppGlanceService *service) {
if (service && service->handlers.glance_changed) {
service->handlers.glance_changed(service->handlers_context);
}
}
void launcher_app_glance_service_init(LauncherAppGlanceService *service,
uint32_t generic_glance_icon_resource_id) {
if (!service) {
return;
}
*service = (LauncherAppGlanceService) {};
prv_reset_slice_expiration_timer(service);
service->glance_event_info = (EventServiceInfo) {
.type = PEBBLE_APP_GLANCE_EVENT,
.handler = prv_handle_glance_event,
.context = service,
};
event_service_client_subscribe(&service->glance_event_info);
service->generic_glance_icon = kino_reel_create_with_resource(generic_glance_icon_resource_id);
PBL_ASSERTN(service->generic_glance_icon);
service->generic_glance_icon_resource_id = generic_glance_icon_resource_id;
const KinoPlayerCallbacks glance_reel_player_callbacks = (KinoPlayerCallbacks) {
.frame_did_change = prv_glance_reel_player_frame_did_change_cb,
};
kino_player_set_callbacks(&service->glance_reel_player, glance_reel_player_callbacks, service);
}
void launcher_app_glance_service_set_handlers(LauncherAppGlanceService *service,
const LauncherAppGlanceServiceHandlers *handlers,
void *context) {
if (!service) {
return;
} else if (!handlers) {
service->handlers = (LauncherAppGlanceServiceHandlers) {};
} else {
service->handlers = *handlers;
}
service->handlers_context = context;
}
void launcher_app_glance_service_deinit(LauncherAppGlanceService *service) {
if (!service) {
return;
}
kino_player_deinit(&service->glance_reel_player);
event_service_client_unsubscribe(&service->glance_event_info);
prv_glance_cache_deinit(service);
prv_reset_slice_expiration_timer(service);
kino_reel_destroy(service->generic_glance_icon);
}

View file

@ -0,0 +1,100 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "applib/app_timer.h"
#include "applib/event_service_client.h"
#include "applib/ui/kino/kino_player.h"
#include "process_management/app_menu_data_source.h"
#include "util/list.h"
//! Handler called when a glance in the service's cache changes, either because a glance's slice
//! expired or a glance was reloaded.
//! @param context Context provided when calling launcher_app_glance_service_set_handlers()
typedef void (*LauncherAppGlanceServiceGlanceChangedHandler)(void *context);
typedef struct LauncherAppGlanceServiceHandlers {
LauncherAppGlanceServiceGlanceChangedHandler glance_changed;
} LauncherAppGlanceServiceHandlers;
typedef struct LauncherAppGlanceService {
//! Cache of launcher app glances
ListNode *glance_cache;
//! Event service info used to subscribe to glance reload events
EventServiceInfo glance_event_info;
//! Client handlers set via launcher_app_glance_service_set_handlers()
LauncherAppGlanceServiceHandlers handlers;
//! Context for the handlers set via launcher_app_glance_service_set_handlers()
void *handlers_context;
//! The Unix epoch UTC timestamp of the next expiring slice of any of the glances in the cache
time_t next_slice_expiration_time;
//! App timer used for updating glances when a slice of a glance in the cache expires
AppTimer *slice_expiration_timer;
//! A generic icon to use for generic glances that can't otherwise load an icon
KinoReel *generic_glance_icon;
//! The resource ID of the generic glance icon
uint32_t generic_glance_icon_resource_id;
//! A \ref KinoReelPlayer for the currently selected glance
KinoPlayer glance_reel_player;
} LauncherAppGlanceService;
//! Initialize the provided launcher app glance service.
//! @param service The launcher app glance service to initialize
//! @param generic_glance_icon_resource_id A resource ID to use if a generic launcher app glance
//! does not otherwise have an icon to draw
void launcher_app_glance_service_init(LauncherAppGlanceService *service,
uint32_t generic_glance_icon_resource_id);
void launcher_app_glance_service_set_handlers(LauncherAppGlanceService *service,
const LauncherAppGlanceServiceHandlers *handlers,
void *context);
//! Deinitialize the provided launcher app glance service.
//! @param service The launcher app glance service to deinitialize
void launcher_app_glance_service_deinit(LauncherAppGlanceService *service);
//! Draw the launcher app glance for the provided app node.
//! @param service The service to use to draw the launcher app glance
//! @param ctx The graphics context to use to draw the launcher app glance
//! @param frame The frame in which to draw the launcher app glance
//! @param is_highlighted Whether or not the launcher app glance should be drawn highlighted
//! @param node The \ref AppMenuNode of the app whose glance we should draw
void launcher_app_glance_service_draw_glance_for_app_node(LauncherAppGlanceService *service,
GContext *ctx, const GRect *frame,
bool is_highlighted, AppMenuNode *node);
//! Rewind any glance being played by the provided launcher app glance service.
//! @param service The service for which to rewind any playing glance
void launcher_app_glance_service_rewind_current_glance(LauncherAppGlanceService *service);
//! Pause any glance being played by the provided launcher app glance service.
//! @param service The service for which to pause any playing glance
void launcher_app_glance_service_pause_current_glance(LauncherAppGlanceService *service);
//! Start playing the current glance for the provided launcher app glance service.
//! @param service The service for which to play the current glance
void launcher_app_glance_service_play_current_glance(LauncherAppGlanceService *service);
//! Play the launcher app glance for the provided app node.
//! @param service The service to use to play the launcher app glance
//! @param node The \ref AppMenuNode for the glance to play
void launcher_app_glance_service_play_glance_for_app_node(LauncherAppGlanceService *service,
AppMenuNode *node);
//! Notify the service that a launcher app glance in its cache changed.
//! @param service The service to notify
void launcher_app_glance_service_notify_glance_changed(LauncherAppGlanceService *service);

View file

@ -0,0 +1,451 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_settings.h"
#include "launcher_app_glance_structured.h"
#include "launcher_menu_layer.h"
#include "applib/battery_state_service.h"
#include "applib/graphics/gpath.h"
#include "apps/system_apps/timeline/text_node.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/common/battery/battery_state.h"
#include "services/common/comm_session/session.h"
#include "services/normal/bluetooth/ble_hrm.h"
#include "services/normal/notifications/alerts_private.h"
#include "services/normal/notifications/do_not_disturb.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/size.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
// These dimensions are separate defines so we can use them to statically define the battery points
#define BATTERY_SILHOUETTE_ICON_WIDTH (16)
#define BATTERY_SILHOUETTE_ICON_HEIGHT (9)
typedef struct LauncherAppGlanceSettingsState {
BatteryChargeState battery_charge_state;
bool is_pebble_app_connected;
bool is_airplane_mode_enabled;
bool is_quiet_time_enabled;
#if CAPABILITY_HAS_BUILTIN_HRM
bool is_sharing_hrm;
#endif
} LauncherAppGlanceSettingsState;
typedef struct LauncherAppGlanceSettings {
char title[APP_NAME_SIZE_BYTES];
char battery_percent_text[5]; //!< longest string is "100%" (4 characters + 1 for NULL terminator)
KinoReel *icon;
uint32_t icon_resource_id;
KinoReel *charging_indicator_icon;
uint8_t subtitle_font_height;
LauncherAppGlanceSettingsState glance_state;
EventServiceInfo battery_state_event_info;
EventServiceInfo pebble_app_event_info;
EventServiceInfo airplane_mode_event_info;
EventServiceInfo quiet_time_event_info;
#if CAPABILITY_HAS_BUILTIN_HRM
EventServiceInfo hrm_sharing_event_info;
#endif
} LauncherAppGlanceSettings;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(settings_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(settings_glance, title, NULL);
}
static void prv_charging_icon_node_draw_cb(GContext *ctx, const GRect *rect,
UNUSED const GTextNodeDrawConfig *config, bool render,
GSize *size_out, void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
KinoReel *charging_indicator_icon = NULL_SAFE_FIELD_ACCESS(settings_glance,
charging_indicator_icon, NULL);
PBL_ASSERTN(charging_indicator_icon);
if (render && charging_indicator_icon) {
launcher_app_glance_structured_draw_icon(structured_glance, ctx, charging_indicator_icon,
rect->origin);
}
if (size_out) {
*size_out = GSize(kino_reel_get_size(charging_indicator_icon).w,
settings_glance->subtitle_font_height);
}
}
static void prv_battery_icon_node_draw_cb(GContext *ctx, const GRect *rect,
UNUSED const GTextNodeDrawConfig *config, bool render,
GSize *size_out, void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
const GSize battery_silhouette_icon_size = GSize(BATTERY_SILHOUETTE_ICON_WIDTH,
BATTERY_SILHOUETTE_ICON_HEIGHT);
if (render) {
// This points array is static to help conserve stack usage
static const GPoint s_battery_silhouette_path_points[] = {
{0, 0},
{BATTERY_SILHOUETTE_ICON_WIDTH - 1, 0},
{BATTERY_SILHOUETTE_ICON_WIDTH - 1, 1},
{BATTERY_SILHOUETTE_ICON_WIDTH + 1, 2},
{BATTERY_SILHOUETTE_ICON_WIDTH + 1, BATTERY_SILHOUETTE_ICON_HEIGHT - 3},
{BATTERY_SILHOUETTE_ICON_WIDTH - 1, BATTERY_SILHOUETTE_ICON_HEIGHT - 3},
{BATTERY_SILHOUETTE_ICON_WIDTH - 1, BATTERY_SILHOUETTE_ICON_HEIGHT - 1},
{0, BATTERY_SILHOUETTE_ICON_HEIGHT - 1},
};
GPath battery_silhouette_path = (GPath) {
.num_points = ARRAY_LENGTH(s_battery_silhouette_path_points),
.points = (GPoint *)s_battery_silhouette_path_points,
.offset = rect->origin,
};
const GColor battery_silhouette_color =
launcher_app_glance_structured_get_highlight_color(structured_glance);
const GColor battery_fill_color =
PBL_IF_COLOR_ELSE(gcolor_legible_over(battery_silhouette_color), GColorWhite);
graphics_context_set_fill_color(ctx, battery_silhouette_color);
// Draw the battery silhouette
const GRect battery_silhouette_frame = (GRect) {
.origin = rect->origin,
.size = battery_silhouette_icon_size,
};
gpath_draw_filled(ctx, &battery_silhouette_path);
// Inset the filled area
GRect battery_fill_rect = grect_inset_internal(battery_silhouette_frame, 3, 2);
#if !PBL_COLOR
// Fill the battery silhouette all the way for B&W, in order to make the BG black always.
graphics_context_set_fill_color(ctx, GColorBlack);
graphics_fill_rect(ctx, &battery_fill_rect);
#endif
// Adjust fill width for charge percentage, never filling below 10%
uint8_t clipped_charge_percent =
settings_glance->glance_state.battery_charge_state.charge_percent;
clipped_charge_percent = CLIP(clipped_charge_percent, (uint8_t)10, (uint8_t)100);
battery_fill_rect.size.w = battery_fill_rect.size.w * clipped_charge_percent / (int16_t)100;
// Fill the battery silhouette based on the charge percent
graphics_context_set_fill_color(ctx, battery_fill_color);
graphics_fill_rect(ctx, &battery_fill_rect);
}
if (size_out) {
*size_out = GSize(battery_silhouette_icon_size.w, settings_glance->subtitle_font_height);
}
}
static void prv_battery_percent_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (settings_glance) {
buffer_size = MIN(sizeof(settings_glance->battery_percent_text), buffer_size);
strncpy(buffer, settings_glance->battery_percent_text, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_wrap_text_node_in_vertically_centered_container(GTextNode *node) {
const size_t max_vertical_container_nodes = 1;
GTextNodeVertical *vertical_container_node =
graphics_text_node_create_vertical(max_vertical_container_nodes);
vertical_container_node->vertical_alignment = GVerticalAlignmentCenter;
graphics_text_node_container_add_child(&vertical_container_node->container, node);
return &vertical_container_node->container.node;
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
PBL_ASSERTN(structured_glance);
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
PBL_ASSERTN(settings_glance);
// Battery text (if not plugged in), battery icon, and (if plugged in) a lightning bolt icon
const size_t max_horizontal_nodes = 3;
GTextNodeHorizontal *horizontal_container_node =
graphics_text_node_create_horizontal(max_horizontal_nodes);
horizontal_container_node->horizontal_alignment = GTextAlignmentLeft;
if (!settings_glance->glance_state.battery_charge_state.is_plugged) {
GTextNode *battery_percent_text_node =
launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_battery_percent_dynamic_text_node_update);
// Achieves the design spec'd 6 px horizontal spacing b/w the percent text and battery icon
battery_percent_text_node->margin.w = 4;
GTextNode *vertically_centered_battery_percent_text_node =
prv_wrap_text_node_in_vertically_centered_container(battery_percent_text_node);
graphics_text_node_container_add_child(&horizontal_container_node->container,
vertically_centered_battery_percent_text_node);
}
#if PLATFORM_ROBERT
const int16_t subtitle_icon_offset_y = 5;
#else
const int16_t subtitle_icon_offset_y = 2;
#endif
GTextNodeCustom *battery_icon_node =
graphics_text_node_create_custom(prv_battery_icon_node_draw_cb, structured_glance);
// Push the battery icon down to center it properly
battery_icon_node->node.offset.y += subtitle_icon_offset_y;
// Achieves the design spec'd 6 px horizontal spacing b/w the battery icon and charging icon
battery_icon_node->node.margin.w = 7;
GTextNode *vertically_centered_battery_icon_node =
prv_wrap_text_node_in_vertically_centered_container(&battery_icon_node->node);
graphics_text_node_container_add_child(&horizontal_container_node->container,
vertically_centered_battery_icon_node);
if (settings_glance->glance_state.battery_charge_state.is_plugged) {
GTextNodeCustom *charging_icon_node =
graphics_text_node_create_custom(prv_charging_icon_node_draw_cb, structured_glance);
// Push the charging icon down to center it properly
charging_icon_node->node.offset.y += subtitle_icon_offset_y;
GTextNode *vertically_centered_charging_icon_node =
prv_wrap_text_node_in_vertically_centered_container(&charging_icon_node->node);
graphics_text_node_container_add_child(&horizontal_container_node->container,
vertically_centered_charging_icon_node);
}
return &horizontal_container_node->container.node;
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (settings_glance) {
event_service_client_unsubscribe(&settings_glance->battery_state_event_info);
event_service_client_unsubscribe(&settings_glance->pebble_app_event_info);
event_service_client_unsubscribe(&settings_glance->airplane_mode_event_info);
event_service_client_unsubscribe(&settings_glance->quiet_time_event_info);
#if CAPABILITY_HAS_BUILTIN_HRM
event_service_client_unsubscribe(&settings_glance->hrm_sharing_event_info);
#endif
kino_reel_destroy(settings_glance->icon);
kino_reel_destroy(settings_glance->charging_indicator_icon);
}
app_free(settings_glance);
}
static void prv_set_glance_icon(LauncherAppGlanceSettings *settings_glance,
uint32_t new_icon_resource_id) {
if (settings_glance->icon_resource_id == new_icon_resource_id) {
// Nothing to do, bail out
return;
}
// Destroy the existing icon
kino_reel_destroy(settings_glance->icon);
// Set the new icon and record its resource ID
settings_glance->icon = kino_reel_create_with_resource(new_icon_resource_id);
PBL_ASSERTN(settings_glance->icon);
settings_glance->icon_resource_id = new_icon_resource_id;
}
static bool prv_mute_notifications_allow_calls_only(void) {
return (alerts_get_mask() == AlertMaskPhoneCalls);
}
static uint32_t prv_get_resource_id_for_connectivity_status(
LauncherAppGlanceSettings *settings_glance) {
#if CAPABILITY_HAS_BUILTIN_HRM
if (settings_glance->glance_state.is_sharing_hrm) {
return RESOURCE_ID_CONNECTIVITY_SHARING_HRM;
}
#endif
if (settings_glance->glance_state.is_airplane_mode_enabled) {
return RESOURCE_ID_CONNECTIVITY_BLUETOOTH_AIRPLANE_MODE;
} else if (!settings_glance->glance_state.is_pebble_app_connected) {
return RESOURCE_ID_CONNECTIVITY_BLUETOOTH_DISCONNECTED;
} else if (settings_glance->glance_state.is_quiet_time_enabled) {
return RESOURCE_ID_CONNECTIVITY_BLUETOOTH_DND;
} else if (prv_mute_notifications_allow_calls_only()) {
return RESOURCE_ID_CONNECTIVITY_BLUETOOTH_CALLS_ONLY;
} else if (settings_glance->glance_state.is_pebble_app_connected) {
return RESOURCE_ID_CONNECTIVITY_BLUETOOTH_CONNECTED;
} else {
WTF;
}
}
static void prv_refresh_glance_content(LauncherAppGlanceSettings *settings_glance) {
// Update the battery percent text in the glance
const size_t battery_percent_text_size = sizeof(settings_glance->battery_percent_text);
snprintf(settings_glance->battery_percent_text, battery_percent_text_size, "%"PRIu8"%%",
settings_glance->glance_state.battery_charge_state.charge_percent);
// Update the icon
const uint32_t new_icon_resource_id =
prv_get_resource_id_for_connectivity_status(settings_glance);
prv_set_glance_icon(settings_glance, new_icon_resource_id);
}
static bool prv_is_pebble_app_connected(void) {
return (comm_session_get_system_session() != NULL);
}
static void prv_event_handler(PebbleEvent *event, void *context) {
LauncherAppGlanceStructured *structured_glance = context;
PBL_ASSERTN(structured_glance);
LauncherAppGlanceSettings *settings_glance =
launcher_app_glance_structured_get_data(structured_glance);
PBL_ASSERTN(settings_glance);
switch (event->type) {
case PEBBLE_BATTERY_STATE_CHANGE_EVENT:
settings_glance->glance_state.battery_charge_state = battery_state_service_peek();
break;
case PEBBLE_COMM_SESSION_EVENT:
if (event->bluetooth.comm_session_event.is_system) {
settings_glance->glance_state.is_pebble_app_connected =
event->bluetooth.comm_session_event.is_open;
}
break;
case PEBBLE_BT_STATE_EVENT:
settings_glance->glance_state.is_airplane_mode_enabled = bt_ctl_is_airplane_mode_on();
break;
case PEBBLE_DO_NOT_DISTURB_EVENT:
settings_glance->glance_state.is_quiet_time_enabled = do_not_disturb_is_active();
break;
#if CAPABILITY_HAS_BUILTIN_HRM
case PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT: {
const bool prev_is_sharing = settings_glance->glance_state.is_sharing_hrm;
const bool is_sharing = (event->bluetooth.le.hrm_sharing_state.subscription_count > 0);
if (prev_is_sharing == is_sharing) {
return;
}
settings_glance->glance_state.is_sharing_hrm = is_sharing;
break;
}
#endif
default:
WTF;
}
// Refresh the content in the glance
prv_refresh_glance_content(settings_glance);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static void prv_subscribe_to_event(EventServiceInfo *event_service_info, PebbleEventType type,
LauncherAppGlanceStructured *structured_glance) {
PBL_ASSERTN(event_service_info);
*event_service_info = (EventServiceInfo) {
.type = type,
.handler = prv_event_handler,
.context = structured_glance,
};
event_service_client_subscribe(event_service_info);
}
static const LauncherAppGlanceStructuredImpl s_settings_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_settings_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceSettings *settings_glance = app_zalloc_check(sizeof(*settings_glance));
// Copy the name of the Settings app as the title
const size_t title_size = sizeof(settings_glance->title);
strncpy(settings_glance->title, node->name, title_size);
settings_glance->title[title_size - 1] = '\0';
// Load the charging indicator icon
settings_glance->charging_indicator_icon =
kino_reel_create_with_resource(RESOURCE_ID_BATTERY_CHARGING_ICON);
// Cache the subtitle font height for simplifying layout calculations
settings_glance->subtitle_font_height =
fonts_get_font_height(fonts_get_system_font(LAUNCHER_MENU_LAYER_SUBTITLE_FONT));
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_settings_structured_glance_impl,
should_consider_slices, settings_glance);
PBL_ASSERTN(structured_glance);
// Disable selection animations for the settings glance
structured_glance->selection_animation_disabled = true;
// Set the first state of the glance
settings_glance->glance_state = (LauncherAppGlanceSettingsState) {
.battery_charge_state = battery_state_service_peek(),
.is_pebble_app_connected = prv_is_pebble_app_connected(),
.is_airplane_mode_enabled = bt_ctl_is_airplane_mode_on(),
.is_quiet_time_enabled = do_not_disturb_is_active(),
#if CAPABILITY_HAS_BUILTIN_HRM
.is_sharing_hrm = ble_hrm_is_sharing(),
#endif
};
// Refresh the glance now that we have set the first state of the glance
prv_refresh_glance_content(settings_glance);
// Subscribe to the various events we care about
prv_subscribe_to_event(&settings_glance->battery_state_event_info,
PEBBLE_BATTERY_STATE_CHANGE_EVENT, structured_glance);
prv_subscribe_to_event(&settings_glance->pebble_app_event_info, PEBBLE_COMM_SESSION_EVENT,
structured_glance);
prv_subscribe_to_event(&settings_glance->airplane_mode_event_info, PEBBLE_BT_STATE_EVENT,
structured_glance);
prv_subscribe_to_event(&settings_glance->quiet_time_event_info, PEBBLE_DO_NOT_DISTURB_EVENT,
structured_glance);
#if CAPABILITY_HAS_BUILTIN_HRM
prv_subscribe_to_event(&settings_glance->hrm_sharing_event_info,
PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT,
structured_glance);
#endif
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_settings_create(const AppMenuNode *node);

View file

@ -0,0 +1,547 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_structured.h"
#include "launcher_app_glance_private.h"
#include "launcher_menu_layer.h"
#include "applib/graphics/gdraw_command_transforms.h"
#include "applib/ui/kino/kino_reel_custom.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/normal/timeline/attribute.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#if PLATFORM_ROBERT
#define LAUNCHER_APP_GLANCE_STRUCTURED_ICON_HORIZONTAL_MARGIN (9)
#else
#define LAUNCHER_APP_GLANCE_STRUCTURED_ICON_HORIZONTAL_MARGIN (5)
#endif
typedef struct GenericGlanceIconDrawCommandProcessor {
GDrawCommandProcessor draw_command_processor;
GColor8 *luminance_tint_lookup_table;
} GenericGlanceIconDrawCommandProcessor;
static void prv_structured_glance_icon_draw_command_processor_process_command(
GDrawCommandProcessor *processor, GDrawCommand *processed_command,
UNUSED size_t processed_command_max_size, UNUSED const GDrawCommandList *list,
UNUSED const GDrawCommand *command) {
GenericGlanceIconDrawCommandProcessor *processor_with_data =
(GenericGlanceIconDrawCommandProcessor *)processor;
const GColor8 *luminance_tint_lookup_table = processor_with_data->luminance_tint_lookup_table;
// Luminance tint the fill color
const GColor fill_color = gdraw_command_get_fill_color(processed_command);
const GColor tinted_fill_color =
gcolor_perform_lookup_using_color_luminance_and_multiply_alpha(fill_color,
luminance_tint_lookup_table);
gdraw_command_replace_color(processed_command, fill_color, tinted_fill_color);
gdraw_command_set_fill_color(processed_command, tinted_fill_color);
// Luminance tint the stroke color
const GColor stroke_color = gdraw_command_get_stroke_color(processed_command);
const GColor tinted_stroke_color =
gcolor_perform_lookup_using_color_luminance_and_multiply_alpha(stroke_color,
luminance_tint_lookup_table);
gdraw_command_set_stroke_color(processed_command, tinted_stroke_color);
}
typedef struct GenericGlanceIconBitmapProcessor {
GBitmapProcessor bitmap_processor;
GCompOp saved_compositing_mode;
GColor saved_tint_color;
GColor desired_tint_color;
} GenericGlanceIconBitmapProcessor;
static void prv_strucutred_glance_icon_bitmap_processor_pre_func(
GBitmapProcessor *processor, GContext *ctx, UNUSED const GBitmap **bitmap_to_use,
UNUSED GRect *global_grect_to_use) {
GenericGlanceIconBitmapProcessor *processor_with_data =
(GenericGlanceIconBitmapProcessor *)processor;
// Save the current compositing mode and tint color
processor_with_data->saved_compositing_mode = ctx->draw_state.compositing_mode;
processor_with_data->saved_tint_color = ctx->draw_state.tint_color;
// Set the compositing mode so that we luminance tint the icon to the specified color
ctx->draw_state.compositing_mode = GCompOpTintLuminance;
ctx->draw_state.tint_color = processor_with_data->desired_tint_color;
}
static void prv_structured_glance_icon_bitmap_processor_post_func(
GBitmapProcessor *processor, GContext *ctx, UNUSED const GBitmap *bitmap_used,
UNUSED const GRect *global_clipped_grect_used) {
GenericGlanceIconBitmapProcessor *processor_with_data =
(GenericGlanceIconBitmapProcessor *)processor;
// Restore the saved compositing mode and tint color
ctx->draw_state.compositing_mode = processor_with_data->saved_compositing_mode;
ctx->draw_state.tint_color = processor_with_data->saved_tint_color;
}
GColor launcher_app_glance_structured_get_highlight_color(
LauncherAppGlanceStructured *structured_glance) {
return PBL_IF_COLOR_ELSE(GColorBlack,
structured_glance->glance.is_highlighted ? GColorWhite : GColorBlack);
}
void launcher_app_glance_structured_draw_icon(LauncherAppGlanceStructured *structured_glance,
GContext *ctx, KinoReel *icon, GPoint origin) {
const GColor desired_tint_color =
launcher_app_glance_structured_get_highlight_color(structured_glance);
GenericGlanceIconBitmapProcessor structured_glance_icon_bitmap_processor = {
.bitmap_processor = {
.pre = prv_strucutred_glance_icon_bitmap_processor_pre_func,
.post = prv_structured_glance_icon_bitmap_processor_post_func,
},
.desired_tint_color = desired_tint_color,
};
GColor8 luminance_tint_lookup_table[GCOLOR8_COMPONENT_NUM_VALUES] = {};
gcolor_tint_luminance_lookup_table_init(desired_tint_color, luminance_tint_lookup_table);
GenericGlanceIconDrawCommandProcessor strucutred_glance_icon_draw_command_processor = {
.draw_command_processor.command =
prv_structured_glance_icon_draw_command_processor_process_command,
.luminance_tint_lookup_table = luminance_tint_lookup_table,
};
KinoReelProcessor structured_glance_icon_processor = {
.bitmap_processor = &structured_glance_icon_bitmap_processor.bitmap_processor,
.draw_command_processor =
&strucutred_glance_icon_draw_command_processor.draw_command_processor,
};
// Draw the glance's icon, luminance tinting its colors according to the glance's highlight
kino_reel_draw_processed(icon, ctx, origin, &structured_glance_icon_processor);
}
static void prv_structured_glance_icon_node_draw_cb(GContext *ctx, const GRect *rect,
UNUSED const GTextNodeDrawConfig *config,
bool render, GSize *size_out, void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
KinoReel *icon = NULL;
if (structured_glance && structured_glance->impl && structured_glance->impl->get_icon) {
icon = structured_glance->impl->get_icon(structured_glance);
}
if (render && icon) {
// Center the frame in which we'll draw the icon
GRect icon_frame = (GRect) { .size = kino_reel_get_size(icon) };
grect_align(&icon_frame, rect, GAlignCenter, false /* clip */);
// Save the GContext's clip box and override it so we clip the icon to the max icon size
const GRect saved_clip_box = ctx->draw_state.clip_box;
ctx->draw_state.clip_box.origin = gpoint_add(ctx->draw_state.drawing_box.origin,
rect->origin);
ctx->draw_state.clip_box.size = rect->size;
// Prevent drawing outside of the existing clip box
grect_clip(&ctx->draw_state.clip_box, &saved_clip_box);
// Draw the icon!
launcher_app_glance_structured_draw_icon(structured_glance, ctx, icon, icon_frame.origin);
// Restore the saved clip box
ctx->draw_state.clip_box = saved_clip_box;
}
if (size_out) {
*size_out = structured_glance->icon_max_size;
}
}
static GTextNode *prv_structured_glance_create_text_node(
LauncherAppGlanceStructured *structured_glance, GFont font, size_t buffer_size,
GTextNodeTextDynamicUpdate update) {
if (!structured_glance) {
return NULL;
}
GTextNodeTextDynamic *dynamic_text_node =
graphics_text_node_create_text_dynamic(buffer_size, update, structured_glance);
GTextNodeText *underlying_text_node_text = &dynamic_text_node->text;
underlying_text_node_text->font = font;
underlying_text_node_text->color =
launcher_app_glance_structured_get_highlight_color(structured_glance);
underlying_text_node_text->overflow = GTextOverflowModeTrailingEllipsis;
underlying_text_node_text->node.offset = GPoint(0, -fonts_get_font_cap_offset(font));
underlying_text_node_text->max_size.h = fonts_get_font_height(font);
return &underlying_text_node_text->node;
}
static void prv_structured_glance_title_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
const char *title = NULL;
if (structured_glance && structured_glance->impl && structured_glance->impl->get_title) {
title = structured_glance->impl->get_title(structured_glance);
}
if (title) {
strncpy(buffer, title, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_structured_glance_create_title_text_node(
LauncherAppGlanceStructured *structured_glance) {
return prv_structured_glance_create_text_node(
structured_glance, structured_glance->title_font, APP_NAME_SIZE_BYTES,
prv_structured_glance_title_dynamic_text_node_update);
}
typedef struct ScrollAnimationVars {
int16_t total_px_to_scroll;
int16_t current_offset;
uint32_t duration_ms;
} ScrollAnimationVars;
//! Calculates the variables of a text scrolling animation that proceeds as follows:
//! - Pauses a bit at the start
//! - Scrolls the provided text at a moderate pace up to 3x the width of the provided draw_box
//! - Pauses a bit when the end of the scrollable text is reached
//! - Rewinds the text back to a zero offset at a rapid pace
//! Returns true if conditions are right for scrolling and output arguments should be used,
//! false otherwise.
static bool prv_get_text_scroll_vars(GContext *ctx, uint32_t cumulative_elapsed_ms,
const char *text, const GRect *draw_box, GFont font,
GTextAlignment text_alignment, GTextOverflowMode overflow_mode,
TextLayoutExtended *layout, ScrollAnimationVars *vars_out) {
if (!vars_out) {
return false;
}
// Allow for showing up to 3x the width of the draw_box for the text
const int16_t max_text_width = draw_box->size.w * (int16_t)3;
const GRect max_text_box = (GRect) { .size = GSize(max_text_width, draw_box->size.h) };
const int16_t scroll_visible_text_width =
graphics_text_layout_get_max_used_size(ctx, text, font, max_text_box,
overflow_mode, text_alignment,
(GTextLayoutCacheRef)layout).w;
if (scroll_visible_text_width <= draw_box->size.w) {
// No need to scroll because text fits completely in the provided draw_box
return false;
}
// This is the amount we'll scroll the text from start to end to show all of the
// scroll_visible_text_width in the provided draw_box
const int16_t total_px_to_scroll = scroll_visible_text_width - draw_box->size.w;
vars_out->total_px_to_scroll = total_px_to_scroll;
// These values were tuned with feedback from Design
const uint32_t normal_scroll_speed_ms_per_px = 20;
const uint32_t normal_scroll_duration_ms = total_px_to_scroll * normal_scroll_speed_ms_per_px;
const uint32_t rewind_scroll_speed_ms_per_px = 2;
const uint32_t rewind_scroll_duration_ms = total_px_to_scroll * rewind_scroll_speed_ms_per_px;
const uint32_t pause_at_start_ms = 600;
const uint32_t pause_at_end_ms = 750;
const uint32_t scroll_duration_ms =
pause_at_start_ms + normal_scroll_duration_ms + pause_at_end_ms + rewind_scroll_duration_ms;
vars_out->duration_ms = scroll_duration_ms;
// Technically mod isn't necessary right now, but it's needed for looping eventually (PBL-40544)
int64_t elapsed_ms = cumulative_elapsed_ms % scroll_duration_ms;
const uint32_t end_of_normal_scroll_duration_ms = pause_at_start_ms + normal_scroll_duration_ms;
bool rewind = false;
if (WITHIN(elapsed_ms, 0, end_of_normal_scroll_duration_ms)) {
elapsed_ms = MAX(elapsed_ms - pause_at_start_ms, 0);
} else if (elapsed_ms < end_of_normal_scroll_duration_ms + pause_at_end_ms) {
elapsed_ms = normal_scroll_duration_ms;
} else {
elapsed_ms = scroll_duration_ms - elapsed_ms;
rewind = true;
}
const uint32_t elapsed_normalized =
((uint32_t)elapsed_ms * ANIMATION_NORMALIZED_MAX) /
(rewind ? rewind_scroll_duration_ms : normal_scroll_duration_ms);
vars_out->current_offset = interpolate_int16(elapsed_normalized, 0, total_px_to_scroll);
return true;
}
//! Currently the subtitle scrolling drives the duration of the overall glance selection animation
//! because we only scroll once, and since we don't know what we're scrolling until this function
//! is called, we need to record the duration of the scrolling animation in this function so the
//! glance's KinoReel reports the correct duration for the overall selection animation.
static void prv_adjust_subtitle_node_for_scrolling_animation(
LauncherAppGlanceStructured *structured_glance, GContext *ctx, GTextNodeText *node_text,
const char *text, const GRect *draw_box) {
const uint32_t cumulative_elapsed_ms = structured_glance->selection_animation_elapsed_ms;
ScrollAnimationVars vars;
if (!prv_get_text_scroll_vars(ctx,
cumulative_elapsed_ms,
text, draw_box, node_text->font,
node_text->alignment, node_text->overflow,
&structured_glance->subtitle_scroll_calc_text_layout, &vars)) {
// No need to scroll because text fits completely on-screen, set the selection animation
// duration to 0 and bail out
structured_glance->selection_animation_duration_ms = 0;
return;
}
// Assumes that the default offset.x for the subtitle node is 0, which is true for generic glances
node_text->node.offset.x = -vars.current_offset;
// Assumes that the default margin.w for the subtitle node is 0, which is true for generic glances
node_text->node.margin.w = (vars.current_offset != 0) ? -vars.total_px_to_scroll : (int16_t)0;
// Record any change in the selection animation's duration
LauncherAppGlanceService *service = structured_glance->glance.service;
if (vars.duration_ms != structured_glance->selection_animation_duration_ms) {
const uint32_t previous_selection_animation_duration_ms =
structured_glance->selection_animation_duration_ms;
structured_glance->selection_animation_duration_ms = vars.duration_ms;
// If we're starting a new scroll or a scroll is currently in-progress, pause and then
// play the animation so it is updated with the new duration (e.g. so we don't stop in a weird
// place because the previous duration is shorter than the new one)
if ((previous_selection_animation_duration_ms == 0) || (cumulative_elapsed_ms != 0)) {
launcher_app_glance_service_pause_current_glance(service);
launcher_app_glance_service_play_current_glance(service);
}
}
}
static void prv_structured_glance_subtitle_dynamic_text_node_update(
GContext *ctx, GTextNode *node, const GRect *box, const GTextNodeDrawConfig *config,
bool render, char *buffer, size_t buffer_size, void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
if (structured_glance->subtitle_update) {
structured_glance->subtitle_update(ctx, node, box, config, render, buffer, buffer_size,
user_data);
}
GTextNodeText *node_text = (GTextNodeText *)node;
if (!render) {
prv_adjust_subtitle_node_for_scrolling_animation(structured_glance, ctx, node_text, buffer,
box);
}
}
GTextNode *launcher_app_glance_structured_create_subtitle_text_node(
LauncherAppGlanceStructured *structured_glance, GTextNodeTextDynamicUpdate update) {
const size_t subtitle_buffer_size = ATTRIBUTE_APP_GLANCE_SUBTITLE_MAX_LEN + 1;
structured_glance->subtitle_update = update;
GTextNode *node = prv_structured_glance_create_text_node(
structured_glance, structured_glance->subtitle_font, subtitle_buffer_size,
prv_structured_glance_subtitle_dynamic_text_node_update);
// Clip subtitle text nodes to their draw box since we scroll them if they're too long
node->clip = true;
return node;
}
static GTextNode *prv_create_structured_glance_title_subtitle_node(
LauncherAppGlanceStructured *structured_glance, const GRect *glance_frame) {
// Title node and subtitle node
const size_t max_vertical_nodes = 2;
GTextNodeVertical *vertical_node = graphics_text_node_create_vertical(max_vertical_nodes);
vertical_node->vertical_alignment = GVerticalAlignmentCenter;
GTextNode *title_node = prv_structured_glance_create_title_text_node(structured_glance);
// We require a valid title node
PBL_ASSERTN(title_node);
// Push the title node a little up or down to match the relevant design spec
#if PLATFORM_ROBERT
title_node->offset.y += 1;
#else
title_node->offset.y -= 1;
#endif
graphics_text_node_container_add_child(&vertical_node->container, title_node);
GTextNode *subtitle_node = NULL;
if (structured_glance->impl && structured_glance->impl->create_subtitle_node) {
subtitle_node = structured_glance->impl->create_subtitle_node(structured_glance);
}
// The subtitle node is optional
if (subtitle_node) {
graphics_text_node_container_add_child(&vertical_node->container, subtitle_node);
}
// Set the vertical container's width to exactly what it should be so it doesn't resize based
// on its changing content (e.g. scrolling subtitle)
vertical_node->container.size.w =
glance_frame->size.w - structured_glance->icon_horizontal_margin -
structured_glance->icon_max_size.w;
return &vertical_node->container.node;
}
// NOINLINE to save stack; on Spalding this can be enough to push us over the edge.
static NOINLINE GTextNode *prv_create_structured_glance_node(
LauncherAppGlanceStructured *structured_glance, const GRect *glance_frame) {
// Icon node and title/subtitle nodes
const size_t max_horizontal_nodes = 2;
GTextNodeHorizontal *horizontal_node = graphics_text_node_create_horizontal(max_horizontal_nodes);
horizontal_node->horizontal_alignment = GTextAlignmentLeft;
// This vertical node is just a container to vertically center the icon node
const size_t max_vertical_icon_container_nodes = 1;
GTextNodeVertical *vertical_icon_container_node =
graphics_text_node_create_vertical(max_vertical_icon_container_nodes);
vertical_icon_container_node->vertical_alignment = GVerticalAlignmentCenter;
// This horizontal node is just a container to horizontally center the icon node
const size_t max_horizontal_icon_container_nodes = 1;
GTextNodeHorizontal *horizontal_icon_container_node =
graphics_text_node_create_horizontal(max_horizontal_icon_container_nodes);
horizontal_icon_container_node->horizontal_alignment = GTextAlignmentCenter;
graphics_text_node_container_add_child(&vertical_icon_container_node->container,
&horizontal_icon_container_node->container.node);
GTextNodeCustom *icon_node =
graphics_text_node_create_custom(prv_structured_glance_icon_node_draw_cb, structured_glance);
icon_node->node.margin.w = structured_glance->icon_horizontal_margin;
// The +1 is to force a rounding up. This way, 3 pixels extra will move closer to the screen
// edge, instead of closer to the text.
icon_node->node.offset.x -= (LAUNCHER_APP_GLANCE_STRUCTURED_ICON_HORIZONTAL_MARGIN -
structured_glance->icon_horizontal_margin + 1) / 2;
graphics_text_node_container_add_child(&horizontal_icon_container_node->container,
&icon_node->node);
graphics_text_node_container_add_child(&horizontal_node->container,
&vertical_icon_container_node->container.node);
GTextNode *title_subtitle_node =
prv_create_structured_glance_title_subtitle_node(structured_glance, glance_frame);
graphics_text_node_container_add_child(&horizontal_node->container,
title_subtitle_node);
return &horizontal_node->container.node;
}
static void prv_draw_processed(KinoReel *reel, GContext *ctx, GPoint offset,
UNUSED KinoReelProcessor *processor) {
LauncherAppGlanceStructured *structured_glance = kino_reel_custom_get_data(reel);
if (!structured_glance) {
return;
}
GRect glance_frame = (GRect) { .origin = offset, .size = structured_glance->glance.size };
#if PLATFORM_ROBERT
const int16_t horizontal_inset = 10;
#else
const int16_t horizontal_inset = PBL_IF_RECT_ELSE(6, 23);
#endif
glance_frame = grect_inset_internal(glance_frame, horizontal_inset, 0);
GTextNode *structured_glance_node = prv_create_structured_glance_node(structured_glance,
&glance_frame);
if (structured_glance_node) {
graphics_text_node_draw(structured_glance_node, ctx, &glance_frame, NULL, NULL);
}
graphics_text_node_destroy(structured_glance_node);
}
static uint32_t prv_get_elapsed(KinoReel *reel) {
LauncherAppGlanceStructured *structured_glance = kino_reel_custom_get_data(reel);
return NULL_SAFE_FIELD_ACCESS(structured_glance, selection_animation_elapsed_ms, 0);
}
static bool prv_set_elapsed(KinoReel *reel, uint32_t elapsed_ms) {
LauncherAppGlanceStructured *structured_glance = kino_reel_custom_get_data(reel);
if (!structured_glance) {
return false;
}
if (!structured_glance->selection_animation_disabled) {
structured_glance->selection_animation_elapsed_ms = elapsed_ms;
}
// We assume the selection animation loops so that it's last frame is the same as its first frame,
// so let's enforce that here so the animation update code above works properly
if (structured_glance->selection_animation_elapsed_ms == kino_reel_get_duration(reel)) {
structured_glance->selection_animation_elapsed_ms = 0;
}
return !structured_glance->selection_animation_disabled;
}
static uint32_t prv_get_duration(KinoReel *reel) {
// TODO PBL-40544: Loop the selection animation
LauncherAppGlanceStructured *structured_glance = kino_reel_custom_get_data(reel);
return NULL_SAFE_FIELD_ACCESS(structured_glance, selection_animation_duration_ms, 0);
}
static void prv_destructor(KinoReel *reel) {
LauncherAppGlanceStructured *structured_glance = kino_reel_custom_get_data(reel);
if (structured_glance && structured_glance->impl && structured_glance->impl->destructor) {
structured_glance->impl->destructor(structured_glance);
}
}
static const KinoReelImpl s_launcher_app_glance_structured_reel_impl = {
.reel_type = KinoReelTypeCustom,
.get_size = launcher_app_glance_get_size_for_reel,
.draw_processed = prv_draw_processed,
.destructor = prv_destructor,
.get_duration = prv_get_duration,
.get_elapsed = prv_get_elapsed,
.set_elapsed = prv_set_elapsed,
};
LauncherAppGlanceStructured *launcher_app_glance_structured_create(
const Uuid *uuid, const LauncherAppGlanceStructuredImpl *impl, bool should_consider_slices,
void *data) {
PBL_ASSERTN(uuid);
LauncherAppGlanceStructured *structured_glance = app_zalloc_check(sizeof(*structured_glance));
const LauncherAppGlanceHandlers *base_handlers = impl ? &impl->base_handlers : NULL;
structured_glance->impl = impl;
structured_glance->data = data;
structured_glance->icon_max_size = LAUNCHER_APP_GLANCE_STRUCTURED_ICON_MAX_SIZE;
structured_glance->icon_horizontal_margin =
LAUNCHER_APP_GLANCE_STRUCTURED_ICON_HORIZONTAL_MARGIN;
structured_glance->title_font = fonts_get_system_font(LAUNCHER_MENU_LAYER_TITLE_FONT);
structured_glance->subtitle_font = fonts_get_system_font(LAUNCHER_MENU_LAYER_SUBTITLE_FONT);
KinoReel *glance_impl = kino_reel_custom_create(&s_launcher_app_glance_structured_reel_impl,
structured_glance);
// Now that we've setup the structured glance's fields, initialize the LauncherAppGlance
launcher_app_glance_init(&structured_glance->glance, uuid, glance_impl, should_consider_slices,
base_handlers);
return structured_glance;
}
void *launcher_app_glance_structured_get_data(LauncherAppGlanceStructured *structured_glance) {
return NULL_SAFE_FIELD_ACCESS(structured_glance, data, NULL);
}
void launcher_app_glance_structured_notify_service_glance_changed(
LauncherAppGlanceStructured *structured_glance) {
if (!structured_glance) {
return;
}
launcher_app_glance_notify_service_glance_changed(&structured_glance->glance);
}
void launcher_app_glance_structured_set_icon_max_size(
LauncherAppGlanceStructured *structured_glance, GSize new_size) {
if (!structured_glance) {
return;
}
structured_glance->icon_max_size = new_size;
const int width_diff = structured_glance->icon_max_size.w -
LAUNCHER_APP_GLANCE_STRUCTURED_ICON_MAX_SIZE.w;
structured_glance->icon_horizontal_margin =
LAUNCHER_APP_GLANCE_STRUCTURED_ICON_HORIZONTAL_MARGIN - width_diff;
if (structured_glance->icon_horizontal_margin < 0) {
structured_glance->icon_horizontal_margin = 0;
}
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "applib/ui/kino/kino_reel.h"
#include "apps/system_apps/timeline/text_node.h"
#include "util/uuid.h"
#define LAUNCHER_APP_GLANCE_STRUCTURED_ICON_MAX_SIZE \
(GSize(ATTRIBUTE_ICON_TINY_SIZE_PX, ATTRIBUTE_ICON_TINY_SIZE_PX))
#define LAUNCHER_APP_GLANCE_STRUCTURED_ICON_LEGACY_MAX_SIZE \
(GSize(28, 28))
//! Forward declaration
typedef struct LauncherAppGlanceStructured LauncherAppGlanceStructured;
//! Function used to get the title to display in the structured launcher app glance.
//! @param structured_glance The structured glance for which to get the title
//! @return The title to display in the structured glance; will be copied so can be short-lived
typedef const char *(*LauncherAppGlanceStructuredTitleGetter)
(LauncherAppGlanceStructured *structured_glance);
//! Function used to create subtitle text nodes for the structured launcher app glance.
//! @param structured_glance The structured glance for which to create a text node
//! @return The text node the structured glance should use
typedef GTextNode *(*LauncherAppGlanceStructuredTextNodeConstructor)
(LauncherAppGlanceStructured *structured_glance);
//! Function called when the structured launcher app glance is being destroyed.
//! @param structured_glance The structured glance that is being destroyed
//! @note This function should NOT free the structured glance; only deinit impl-specific things
typedef void (*LauncherAppGlanceStructuredDestructor)
(LauncherAppGlanceStructured *structured_glance);
//! Function called to request the icon that should be drawn in the structured glance.
//! @param structured_glance The structured glance requesting the icon to draw
//! @return The icon to draw in the structured glance
typedef KinoReel *(*LauncherAppGlanceStructuredIconGetter)
(LauncherAppGlanceStructured *structured_glance);
typedef struct LauncherAppGlanceStructuredImpl {
//! Base handlers for the underlying LauncherAppGlance of the structured glance
LauncherAppGlanceHandlers base_handlers;
//! Called to get the icon to draw in the structured glance
LauncherAppGlanceStructuredIconGetter get_icon;
//! Called to create the title text node for the structured glance; must return a valid text node
LauncherAppGlanceStructuredTitleGetter get_title;
//! Called to create the subtitle text node for the structured glance
LauncherAppGlanceStructuredTextNodeConstructor create_subtitle_node;
//! Called when the structured glance is being destroyed; should NOT free the structured glance
LauncherAppGlanceStructuredDestructor destructor;
} LauncherAppGlanceStructuredImpl;
struct LauncherAppGlanceStructured {
//! The underlying launcher app glance
LauncherAppGlance glance;
//! The implementation of the structured app glance
const LauncherAppGlanceStructuredImpl *impl;
//! The user-provided data for the structured app glance's implementation
void *data;
//! Cached title font that will be used when drawing the structured app glance
GFont title_font;
//! Cached subtitle font that will be used when drawing the structured app glance
GFont subtitle_font;
// Cached text layout used when calculating the width of the subtitle during scrolling
TextLayoutExtended subtitle_scroll_calc_text_layout;
//! Optional implementation-provided dynamic text node update callback for the subtitle
GTextNodeTextDynamicUpdate subtitle_update;
//! Whether or not selection animations should be disabled for this structured app glance
bool selection_animation_disabled;
//! Current cumulative elapsed time (in milliseconds) of the glance's selection animation
uint32_t selection_animation_elapsed_ms;
//! Duration (in milliseconds) of the glance's selection animation
uint32_t selection_animation_duration_ms;
//! Maximum size an icon may have
GSize icon_max_size;
//! Horizontal margin for the icon
int32_t icon_horizontal_margin;
};
_Static_assert((offsetof(LauncherAppGlanceStructured, glance) == 0),
"LauncherAppGlance is not the first field of LauncherAppGlanceStructured");
//! Create a structured launcher app glance for the provided app menu node.
//! @param uuid The UUID of the app for which to initialize this structured glance
//! @param impl The implementation of the structured glance
//! @param should_consider_slices Whether or not the structured glance should consider slices
//! @param data Custom data to use in the implementation of the structured glance
LauncherAppGlanceStructured *launcher_app_glance_structured_create(
const Uuid *uuid, const LauncherAppGlanceStructuredImpl *impl, bool should_consider_slices,
void *data);
//! Get the user-provided data for the implementation of a structured launcher app glance.
//! @param structured_glance The structured glance for which to get the user-provided data
//! @return The user-provided data
void *launcher_app_glance_structured_get_data(LauncherAppGlanceStructured *structured_glance);
//! Get the highlight color that should be used for the provided structured launcher app glance.
//! @param structured_glance The structured glance for which to get the highlight color
//! @return The highlight color to use when drawing the structured glance
GColor launcher_app_glance_structured_get_highlight_color(
LauncherAppGlanceStructured *structured_glance);
//! Draw an icon in the structured launcher app glance.
//! @param structured_glance The structured glance in which to draw an icon
//! @param ctx The graphics context to use when drawing the icon
//! @param icon The icon to draw
//! @param origin The origin at which to draw the icon
void launcher_app_glance_structured_draw_icon(LauncherAppGlanceStructured *structured_glance,
GContext *ctx, KinoReel *icon, GPoint origin);
//! Create a subtitle text node for a structured launcher app glance. It is expected that subclasses
//! of \ref LauncherAppGlanceStructured will use this function in their own custom subtitle node
//! creation functions they specify in their \ref LauncherAppGlanceStructuredImpl. Calling this
//! function saves the provided callback to the \ref LauncherAppGlanceStructured struct, thus you
//! should only call this once per structured glance implementation.
//! @param structured_glance The structured glance for which to create a subtitle text node
//! @param update Callback for updating the text buffer of the text node
//! @return The resulting subtitle text node, or NULL upon failure
GTextNode *launcher_app_glance_structured_create_subtitle_text_node(
LauncherAppGlanceStructured *structured_glance, GTextNodeTextDynamicUpdate update);
//! Notify the structured launcher app glance's service that its content has changed.
//! @param structured_glance The structured glance that has changed
void launcher_app_glance_structured_notify_service_glance_changed(
LauncherAppGlanceStructured *structured_glance);
//! Change the icon max size, and adjust related settings.
//! @param structured_glance The structured glance for which to change the icon size
//! @param new_size The new maximum size allowed for the icon
void launcher_app_glance_structured_set_icon_max_size(
LauncherAppGlanceStructured *structured_glance, GSize new_size);

View file

@ -0,0 +1,120 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_watchfaces.h"
#include "launcher_app_glance_structured.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "shell/normal/watchface.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
typedef struct LauncherAppGlanceWatchfaces {
char title[APP_NAME_SIZE_BYTES];
char subtitle[APP_NAME_SIZE_BYTES];
KinoReel *icon;
} LauncherAppGlanceWatchfaces;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWatchfaces *watchfaces_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(watchfaces_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWatchfaces *watchfaces_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(watchfaces_glance, title, NULL);
}
static void prv_watchfaces_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceWatchfaces *watchfaces_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (watchfaces_glance) {
strncpy(buffer, watchfaces_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_watchfaces_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWatchfaces *watchfaces_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (watchfaces_glance) {
kino_reel_destroy(watchfaces_glance->icon);
}
app_free(watchfaces_glance);
}
static void prv_update_active_watchface_title(LauncherAppGlanceWatchfaces *watchfaces_glance) {
const AppInstallId selected_watch_id = watchface_get_default_install_id();
AppInstallEntry entry;
if (app_install_get_entry_for_install_id(selected_watch_id, &entry)) {
const size_t watchfaces_subtitle_size = sizeof(watchfaces_glance->subtitle);
strncpy(watchfaces_glance->subtitle, entry.name, watchfaces_subtitle_size);
watchfaces_glance->subtitle[watchfaces_subtitle_size - 1] = '\0';
}
}
static const LauncherAppGlanceStructuredImpl s_watchfaces_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_watchfaces_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceWatchfaces *watchfaces_glance = app_zalloc_check(sizeof(*watchfaces_glance));
// Copy the name of the Watchfaces app as the title
const size_t title_size = sizeof(watchfaces_glance->title);
strncpy(watchfaces_glance->title, node->name, title_size);
watchfaces_glance->title[title_size - 1] = '\0';
// Create the icon for the Watchfaces app
watchfaces_glance->icon = kino_reel_create_with_resource_system(node->app_num,
node->icon_resource_id);
PBL_ASSERTN(watchfaces_glance->icon);
// Update the active watchface title in the glance's subtitle
prv_update_active_watchface_title(watchfaces_glance);
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_watchfaces_structured_glance_impl,
should_consider_slices, watchfaces_glance);
PBL_ASSERTN(structured_glance);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_watchfaces_create(const AppMenuNode *node);

View file

@ -0,0 +1,192 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_weather.h"
#include "launcher_app_glance_structured.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/timeline/timeline_resources.h"
#include "services/normal/weather/weather_service.h"
#include "services/normal/weather/weather_types.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
// Max size of the temperature and phrase displayed together
#define WEATHER_APP_GLANCE_MAX_STRING_BUFFER_SIZE (WEATHER_SERVICE_MAX_SHORT_PHRASE_BUFFER_SIZE + 5)
typedef struct LauncherAppGlanceWeather {
char title[APP_NAME_SIZE_BYTES];
char fallback_title[APP_NAME_SIZE_BYTES];
char subtitle[WEATHER_APP_GLANCE_MAX_STRING_BUFFER_SIZE];
KinoReel *icon;
uint32_t icon_resource_id;
EventServiceInfo weather_event_info;
} LauncherAppGlanceWeather;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWeather *weather_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(weather_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWeather *weather_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(weather_glance, title, NULL);
}
static void prv_weather_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceWeather *weather_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (weather_glance) {
strncpy(buffer, weather_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_weather_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWeather *weather_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (weather_glance) {
event_service_client_unsubscribe(&weather_glance->weather_event_info);
kino_reel_destroy(weather_glance->icon);
}
app_free(weather_glance);
}
static uint32_t prv_get_weather_icon_resource_id_for_type(WeatherType type) {
AppResourceInfo res_info;
const bool lookup_success =
timeline_resources_get_id_system(weather_type_get_timeline_resource_id(type),
TimelineResourceSizeTiny, SYSTEM_APP, &res_info);
return lookup_success ? res_info.res_id : (uint32_t)RESOURCE_ID_INVALID;
}
static void prv_weather_event_handler(UNUSED PebbleEvent *event, void *context) {
LauncherAppGlanceStructured *structured_glance = context;
LauncherAppGlanceWeather *weather_glance =
launcher_app_glance_structured_get_data(structured_glance);
PBL_ASSERTN(weather_glance);
WeatherLocationForecast *forecast = weather_service_create_default_forecast();
// Update the icon for the forecast's weather type
const WeatherType weather_type = NULL_SAFE_FIELD_ACCESS(forecast, current_weather_type,
WeatherType_Unknown);
const uint32_t new_weather_icon_resource_id =
prv_get_weather_icon_resource_id_for_type(weather_type);
if (weather_glance->icon_resource_id != new_weather_icon_resource_id) {
kino_reel_destroy(weather_glance->icon);
weather_glance->icon = kino_reel_create_with_resource(new_weather_icon_resource_id);
weather_glance->icon_resource_id = new_weather_icon_resource_id;
}
// Zero out the glance's title buffer
const size_t weather_glance_title_size = sizeof(weather_glance->title);
memset(weather_glance->title, 0, weather_glance_title_size);
// Choose the title we should display based on whether or not we have a forecast
const char *title = NULL_SAFE_FIELD_ACCESS(forecast, location_name,
weather_glance->fallback_title);
// Subtract 1 from the size as a shortcut for null terminating the title since we zero it out
// above
strncpy(weather_glance->title, title, weather_glance_title_size - 1);
// Zero out the glance's subtitle buffer
const size_t weather_glance_subtitle_size = sizeof(weather_glance->subtitle);
memset(weather_glance->subtitle, 0, weather_glance_subtitle_size);
// We'll only set the subtitle if we have a default forecast
if (forecast) {
if (forecast->current_temp == WEATHER_SERVICE_LOCATION_FORECAST_UNKNOWN_TEMP) {
/// Shown when the current temperature is unknown
const char *no_temperature_string = i18n_get("--°", weather_glance);
// Subtract 1 from the size as a shortcut for null terminating the subtitle since we zero it
// out above
strncpy(weather_glance->subtitle, no_temperature_string, weather_glance_subtitle_size - 1);
} else {
/// Shown when today's temperature and conditions phrase is known (e.g. "52° - Fair")
const char *temp_and_phrase_formatter = i18n_get("%i° - %s", weather_glance);
/// Today's current temperature (e.g. "68°")
const char *temp_only_formatter = i18n_get("%i°", weather_glance);
const char *localized_phrase = i18n_get(forecast->current_weather_phrase, weather_glance);
const char *formatter_string = strlen(localized_phrase) ? temp_and_phrase_formatter :
temp_only_formatter;
// It's safe to pass more arguments to snprintf() than might be used by formatter_string
snprintf(weather_glance->subtitle, weather_glance_subtitle_size, formatter_string,
forecast->current_temp, localized_phrase);
}
}
i18n_free_all(weather_glance);
weather_service_destroy_default_forecast(forecast);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static const LauncherAppGlanceStructuredImpl s_weather_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_weather_create(const AppMenuNode *node) {
if (!node) {
return NULL;
}
LauncherAppGlanceWeather *weather_glance = app_zalloc_check(sizeof(*weather_glance));
// Copy the name of the Weather app as a fallback title
const size_t fallback_title_size = sizeof(weather_glance->fallback_title);
strncpy(weather_glance->fallback_title, node->name, fallback_title_size);
weather_glance->fallback_title[fallback_title_size - 1] = '\0';
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_weather_structured_glance_impl,
should_consider_slices, weather_glance);
PBL_ASSERTN(structured_glance);
prv_weather_event_handler(NULL, structured_glance);
weather_glance->weather_event_info = (EventServiceInfo) {
.type = PEBBLE_WEATHER_EVENT,
.handler = prv_weather_event_handler,
.context = structured_glance,
};
event_service_client_subscribe(&weather_glance->weather_event_info);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_weather_create(const AppMenuNode *node);

View file

@ -0,0 +1,218 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app_glance_workout.h"
#include "launcher_app_glance_structured.h"
#include "applib/template_string.h"
#include "apps/system_apps/workout/workout_utils.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_install_manager.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/activity/health_util.h"
#include "services/normal/activity/workout_service.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/string.h"
#include "util/struct.h"
#include <stdio.h>
#define MAX_SUBTITLE_BUFFER_SIZE (16)
typedef struct LauncherAppGlanceWorkout {
char title[APP_NAME_SIZE_BYTES];
char subtitle[MAX_SUBTITLE_BUFFER_SIZE];
KinoReel *icon;
uint32_t icon_resource_id;
AppTimer *timer;
} LauncherAppGlanceWorkout;
static KinoReel *prv_get_icon(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWorkout *workout_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(workout_glance, icon, NULL);
}
static const char *prv_get_title(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWorkout *workout_glance =
launcher_app_glance_structured_get_data(structured_glance);
return NULL_SAFE_FIELD_ACCESS(workout_glance, title, NULL);
}
static void prv_workout_glance_subtitle_dynamic_text_node_update(
UNUSED GContext *ctx, UNUSED GTextNode *node, UNUSED const GRect *box,
UNUSED const GTextNodeDrawConfig *config, UNUSED bool render, char *buffer, size_t buffer_size,
void *user_data) {
LauncherAppGlanceStructured *structured_glance = user_data;
LauncherAppGlanceWorkout *workout_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (workout_glance) {
strncpy(buffer, workout_glance->subtitle, buffer_size);
buffer[buffer_size - 1] = '\0';
}
}
static GTextNode *prv_create_subtitle_node(LauncherAppGlanceStructured *structured_glance) {
return launcher_app_glance_structured_create_subtitle_text_node(
structured_glance, prv_workout_glance_subtitle_dynamic_text_node_update);
}
static void prv_destructor(LauncherAppGlanceStructured *structured_glance) {
LauncherAppGlanceWorkout *workout_glance =
launcher_app_glance_structured_get_data(structured_glance);
if (workout_glance) {
kino_reel_destroy(workout_glance->icon);
app_timer_cancel(workout_glance->timer);
}
app_free(workout_glance);
}
static bool prv_set_glance_icon(LauncherAppGlanceWorkout *workout_glance,
uint32_t new_icon_resource_id) {
if (workout_glance->icon_resource_id == new_icon_resource_id) {
// Nothing to do, bail out
return false;
}
// Destroy the existing icon
kino_reel_destroy(workout_glance->icon);
// Set the new icon and record its resource ID
workout_glance->icon = kino_reel_create_with_resource(new_icon_resource_id);
PBL_ASSERTN(workout_glance->icon);
workout_glance->icon_resource_id = new_icon_resource_id;
return true;
}
static uint32_t prv_get_workout_icon_resource_id_for_type(ActivitySessionType type) {
switch (type) {
case ActivitySessionType_Open:
return RESOURCE_ID_WORKOUT_APP_HEART;
case ActivitySessionType_Walk:
return RESOURCE_ID_WORKOUT_APP_WALK_TINY;
case ActivitySessionType_Run:
return RESOURCE_ID_WORKOUT_APP_RUN_TINY;
case ActivitySessionType_Sleep:
case ActivitySessionType_RestfulSleep:
case ActivitySessionType_Nap:
case ActivitySessionType_RestfulNap:
case ActivitySessionTypeCount:
case ActivitySessionType_None:
break;
}
WTF;
}
static void prv_timer_callback(void *data) {
LauncherAppGlanceStructured *structured_glance = data;
LauncherAppGlanceWorkout *workout_glance =
launcher_app_glance_structured_get_data(structured_glance);
PBL_ASSERTN(workout_glance);
ActivitySession automatic_session = {};
const bool has_automatic_session =
workout_utils_find_ongoing_activity_session(&automatic_session);
ActivitySessionType workout_type;
int32_t workout_duration_s = 0;
if (workout_service_is_workout_ongoing()) {
// Manual workout is going on - get the type and duration
workout_service_get_current_workout_type(&workout_type);
workout_service_get_current_workout_info(NULL, &workout_duration_s, NULL, NULL, NULL);
} else if (has_automatic_session) {
// Automatic workout is going on - get the type and duration
workout_type = automatic_session.type;
workout_duration_s = rtc_get_time() - automatic_session.start_utc;
} else {
// No workout is going on
bool glance_changed = false;
// Set the icon back to default if it isn't already
if (prv_set_glance_icon(workout_glance, RESOURCE_ID_ACTIVITY_TINY)) {
glance_changed = true;
}
// Clear subtitle if it isn't already
if (!IS_EMPTY_STRING(workout_glance->subtitle)) {
memset(workout_glance->subtitle, 0, sizeof(workout_glance->subtitle));
glance_changed = true;
}
// Broadcast to the service that we changed the glance if it was changed
if (glance_changed) {
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
// Bail since no workout is going on
return;
}
// Set icon for the ongoing workout type
prv_set_glance_icon(workout_glance, prv_get_workout_icon_resource_id_for_type(workout_type));
// Zero out the glance's subtitle buffer
memset(workout_glance->subtitle, 0, sizeof(workout_glance->subtitle));
// Set subtitle
health_util_format_hours_minutes_seconds(workout_glance->subtitle,
sizeof(workout_glance->subtitle), workout_duration_s, true, workout_glance);
i18n_free_all(workout_glance);
// Broadcast to the service that we changed the glance
launcher_app_glance_structured_notify_service_glance_changed(structured_glance);
}
static const LauncherAppGlanceStructuredImpl s_workout_structured_glance_impl = {
.get_icon = prv_get_icon,
.get_title = prv_get_title,
.create_subtitle_node = prv_create_subtitle_node,
.destructor = prv_destructor,
};
LauncherAppGlance *launcher_app_glance_workout_create(const AppMenuNode *node) {
PBL_ASSERTN(node);
LauncherAppGlanceWorkout *workout_glance = app_zalloc_check(sizeof(*workout_glance));
// Copy the name of the Workout app as the title
const size_t title_size = sizeof(workout_glance->title);
strncpy(workout_glance->title, node->name, title_size);
workout_glance->title[title_size - 1] = '\0';
const bool should_consider_slices = false;
LauncherAppGlanceStructured *structured_glance =
launcher_app_glance_structured_create(&node->uuid, &s_workout_structured_glance_impl,
should_consider_slices, workout_glance);
PBL_ASSERTN(structured_glance);
// Call timer callback and register it to repeat
prv_timer_callback(structured_glance);
const uint32_t timer_interval_ms = 1000;
workout_glance->timer = app_timer_register_repeatable(timer_interval_ms, prv_timer_callback,
structured_glance, true /* repeating */);
return &structured_glance->glance;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance.h"
#include "process_management/app_menu_data_source.h"
LauncherAppGlance *launcher_app_glance_workout_create(const AppMenuNode *node);

View file

@ -0,0 +1,372 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_menu_layer.h"
#include "launcher_app_glance_service.h"
#include "launcher_menu_layer_private.h"
#include "applib/graphics/gtypes.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/content_indicator.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/normal/timeline/timeline_resources.h"
#include "system/passert.h"
#include "util/attributes.h"
#include "util/struct.h"
#define LAUNCHER_MENU_LAYER_CONTENT_INDICATOR_LAYER_HEIGHT (32)
#define LAUNCHER_MENU_LAYER_GENERIC_APP_ICON (RESOURCE_ID_MENU_LAYER_GENERIC_WATCHAPP_ICON)
////////////////////////////
// Misc. callbacks/helpers
static void prv_launch_app_cb(void *data) {
const AppInstallId app_install_id_to_launch = (AppInstallId)data;
app_manager_put_launch_app_event(&(AppLaunchEventConfig) {
.id = app_install_id_to_launch,
.common.reason = APP_LAUNCH_USER,
.common.button = BUTTON_ID_SELECT,
});
}
static void prv_launcher_menu_layer_mark_dirty(LauncherMenuLayer *launcher_menu_layer) {
if (launcher_menu_layer) {
layer_mark_dirty(menu_layer_get_layer(&launcher_menu_layer->menu_layer));
}
}
//////////////////////////////////////
// LauncherAppGlanceService handlers
static void prv_glance_changed(void *context) {
LauncherMenuLayer *launcher_menu_layer = context;
prv_launcher_menu_layer_mark_dirty(launcher_menu_layer);
}
////////////////////////
// MenuLayer callbacks
static void prv_menu_layer_select(UNUSED MenuLayer *menu_layer, MenuIndex *cell_index,
void *context) {
LauncherMenuLayer *launcher_menu_layer = context;
AppMenuDataSource *data_source = launcher_menu_layer->data_source;
if (!data_source) {
return;
}
Window *window = layer_get_window(launcher_menu_layer_get_layer(launcher_menu_layer));
if (!window) {
return;
}
// Disable all clicking on the window so the user can't scroll anymore
window_set_click_config_provider(window, NULL);
// Capture what app we should launch - we'll actually launch it as part of an app task callback
// we register in our .draw_row callback so that we don't launch the app until after we finish
// rendering the last frame of the menu layer; we need to do this because some clients (like the
// normal firmware app launcher) rely on the display reflecting the final state of the launcher
// when we launch an app (e.g. for compositor transition animations)
AppMenuNode *node = app_menu_data_source_get_node_at_index(data_source, cell_index->row);
PBL_ASSERTN(node);
launcher_menu_layer->app_to_launch_after_next_render = node->install_id;
// Now kick off a render of the last frame of the menu layer; note that any menu layer scroll or
// selection animation has already been advanced to completion by the menu layer before it called
// this select click handler
prv_launcher_menu_layer_mark_dirty(launcher_menu_layer);
}
static uint16_t prv_menu_layer_get_num_rows(UNUSED MenuLayer *menu_layer,
UNUSED uint16_t section_index, void *context) {
LauncherMenuLayer *launcher_menu_layer = context;
AppMenuDataSource *data_source = launcher_menu_layer->data_source;
return data_source ? app_menu_data_source_get_count(data_source) : (uint16_t)0;
}
static void prv_menu_layer_draw_row(GContext* ctx, const Layer *cell_layer, MenuIndex *cell_index,
void *context) {
LauncherMenuLayer *launcher_menu_layer = context;
AppMenuDataSource *data_source = launcher_menu_layer->data_source;
if (!data_source) {
return;
}
AppMenuNode *node = app_menu_data_source_get_node_at_index(data_source, cell_index->row);
const GRect *cell_layer_bounds = &cell_layer->bounds;
const bool is_highlighted = menu_cell_layer_is_highlighted(cell_layer);
launcher_app_glance_service_draw_glance_for_app_node(&launcher_menu_layer->glance_service,
ctx, cell_layer_bounds, is_highlighted,
node);
// If we should launch an app after this render, push a callback to do that on the app task
if (launcher_menu_layer->app_to_launch_after_next_render != INSTALL_ID_INVALID) {
const AppInstallId app_to_launch_install_id =
launcher_menu_layer->app_to_launch_after_next_render;
// Resetting this here in combination with disabling user input in the select click handler
// (the only place that sets this field) ensures we only do this once
launcher_menu_layer->app_to_launch_after_next_render = INSTALL_ID_INVALID;
process_manager_send_callback_event_to_process(PebbleTask_App, prv_launch_app_cb,
(void *)(uintptr_t)app_to_launch_install_id);
}
}
static int16_t prv_menu_layer_get_cell_height(UNUSED MenuLayer *menu_layer,
UNUSED MenuIndex *cell_index, UNUSED void *context) {
#if PBL_RECT
return LAUNCHER_MENU_LAYER_CELL_RECT_CELL_HEIGHT;
#elif PBL_ROUND
return menu_layer_is_index_selected(menu_layer, cell_index) ?
LAUNCHER_MENU_LAYER_CELL_ROUND_FOCUSED_CELL_HEIGHT :
LAUNCHER_MENU_LAYER_CELL_ROUND_UNFOCUSED_CELL_HEIGHT;
#else
#error "Unknown display shape type"
#endif
}
static void prv_play_glance_for_row(LauncherMenuLayer *launcher_menu_layer, uint16_t row) {
if (!launcher_menu_layer || !launcher_menu_layer->selection_animations_enabled) {
return;
}
// Get the app menu node for the glance that is about to be selected
AppMenuDataSource *data_source = launcher_menu_layer->data_source;
AppMenuNode *node = app_menu_data_source_get_node_at_index(data_source, row);
// Instruct the launcher app glance service to play the glance for the node
launcher_app_glance_service_play_glance_for_app_node(&launcher_menu_layer->glance_service, node);
}
static void prv_menu_layer_selection_will_change(MenuLayer *UNUSED menu_layer, MenuIndex *new_index,
MenuIndex UNUSED old_index, void *context) {
LauncherMenuLayer *launcher_menu_layer = context;
prv_play_glance_for_row(launcher_menu_layer, new_index->row);
}
T_STATIC void prv_launcher_menu_layer_set_selection_index(LauncherMenuLayer *launcher_menu_layer,
uint16_t index, MenuRowAlign row_align,
bool animated) {
if (!launcher_menu_layer || !launcher_menu_layer->data_source) {
return;
}
const MenuIndex new_selected_menu_index = MenuIndex(0, index);
menu_layer_set_selected_index(&launcher_menu_layer->menu_layer, new_selected_menu_index,
row_align, animated);
prv_play_glance_for_row(launcher_menu_layer, index);
}
////////////////////////
// Public API
void launcher_menu_layer_init(LauncherMenuLayer *launcher_menu_layer,
AppMenuDataSource *data_source) {
if (!launcher_menu_layer) {
return;
}
// We force the launcher menu layer to be the size of the display so that the calculation of
// LAUNCHER_MENU_LAYER_NUM_VISIBLE_ROWS in launcher_menu_layer_private.h is valid
const GRect frame = DISP_FRAME;
launcher_menu_layer->title_font = fonts_get_system_font(LAUNCHER_MENU_LAYER_TITLE_FONT);
launcher_menu_layer->subtitle_font = fonts_get_system_font(LAUNCHER_MENU_LAYER_SUBTITLE_FONT);
Layer *container_layer = &launcher_menu_layer->container_layer;
layer_init(container_layer, &frame);
launcher_menu_layer->data_source = data_source;
GRect menu_layer_frame = frame;
#if PBL_ROUND
const int top_bottom_inset =
(frame.size.h - LAUNCHER_MENU_LAYER_CELL_ROUND_FOCUSED_CELL_HEIGHT -
(2 * LAUNCHER_MENU_LAYER_CELL_ROUND_UNFOCUSED_CELL_HEIGHT)) / 2;
const GEdgeInsets menu_layer_frame_insets = GEdgeInsets(top_bottom_inset, 0);
menu_layer_frame = grect_inset(menu_layer_frame, menu_layer_frame_insets);
#endif
MenuLayer *menu_layer = &launcher_menu_layer->menu_layer;
menu_layer_init(menu_layer, &menu_layer_frame);
menu_layer_set_highlight_colors(menu_layer,
LAUNCHER_MENU_LAYER_SELECTION_BACKGROUND_COLOR,
PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite));
menu_layer_pad_bottom_enable(menu_layer, false);
menu_layer_set_callbacks(menu_layer, launcher_menu_layer, &(MenuLayerCallbacks) {
.get_num_rows = prv_menu_layer_get_num_rows,
.draw_row = prv_menu_layer_draw_row,
.select_click = prv_menu_layer_select,
.get_cell_height = prv_menu_layer_get_cell_height,
.selection_will_change = prv_menu_layer_selection_will_change,
});
// Only setup the content indicator on round
#if PBL_ROUND
const GSize arrow_layer_frame_size = GSize(frame.size.w,
LAUNCHER_MENU_LAYER_CONTENT_INDICATOR_LAYER_HEIGHT);
const GRect up_arrow_layer_frame = (GRect) {
.size = arrow_layer_frame_size,
};
Layer *up_arrow_layer = &launcher_menu_layer->up_arrow_layer;
layer_init(up_arrow_layer, &up_arrow_layer_frame);
layer_add_child(container_layer, up_arrow_layer);
const int16_t down_arrow_layer_frame_origin_y =
(int16_t)(frame.size.h - LAUNCHER_MENU_LAYER_CONTENT_INDICATOR_LAYER_HEIGHT);
const GRect down_arrow_layer_frame =
grect_inset(frame, GEdgeInsets(down_arrow_layer_frame_origin_y, 0, 0, 0));
Layer *down_arrow_layer = &launcher_menu_layer->down_arrow_layer;
layer_init(down_arrow_layer, &down_arrow_layer_frame);
layer_add_child(container_layer, down_arrow_layer);
ContentIndicator *content_indicator =
scroll_layer_get_content_indicator(&menu_layer->scroll_layer);
ContentIndicatorConfig content_indicator_config = (ContentIndicatorConfig) {
.layer = up_arrow_layer,
.colors.background = GColorWhite,
.colors.foreground = GColorDarkGray,
};
content_indicator_configure_direction(content_indicator, ContentIndicatorDirectionUp,
&content_indicator_config);
content_indicator_config.layer = down_arrow_layer;
content_indicator_configure_direction(content_indicator, ContentIndicatorDirectionDown,
&content_indicator_config);
#endif
// Wait to add the menu layer until after we might have added the content indicators because
// the indicator arrows only get positioned properly if their layers overlap with the menu layer's
// edges
layer_add_child(container_layer, menu_layer_get_layer(menu_layer));
launcher_app_glance_service_init(&launcher_menu_layer->glance_service,
LAUNCHER_MENU_LAYER_GENERIC_APP_ICON);
const LauncherAppGlanceServiceHandlers glance_handlers = (LauncherAppGlanceServiceHandlers) {
.glance_changed = prv_glance_changed,
};
launcher_app_glance_service_set_handlers(&launcher_menu_layer->glance_service,
&glance_handlers, launcher_menu_layer);
// Select the visually first item from the top
const uint16_t first_index = 0;
const bool animated = false;
prv_launcher_menu_layer_set_selection_index(launcher_menu_layer, first_index, MenuRowAlignBottom,
animated);
}
Layer *launcher_menu_layer_get_layer(LauncherMenuLayer *launcher_menu_layer) {
if (!launcher_menu_layer) {
return NULL;
}
return &launcher_menu_layer->container_layer;
}
void launcher_menu_layer_set_click_config_onto_window(LauncherMenuLayer *launcher_menu_layer,
Window *window) {
if (!launcher_menu_layer || !window) {
return;
}
menu_layer_set_click_config_onto_window(&launcher_menu_layer->menu_layer, window);
}
void launcher_menu_layer_reload_data(LauncherMenuLayer *launcher_menu_layer) {
if (!launcher_menu_layer) {
return;
}
menu_layer_reload_data(&launcher_menu_layer->menu_layer);
}
void launcher_menu_layer_set_selection_state(LauncherMenuLayer *launcher_menu_layer,
const LauncherMenuLayerSelectionState *new_state) {
if (!launcher_menu_layer || !launcher_menu_layer->data_source || !new_state) {
return;
}
const bool animated = false;
prv_launcher_menu_layer_set_selection_index(launcher_menu_layer, new_state->row_index,
MenuRowAlignNone, animated);
const GPoint new_scroll_offset = GPoint(0, new_state->scroll_offset_y);
scroll_layer_set_content_offset(&launcher_menu_layer->menu_layer.scroll_layer, new_scroll_offset,
animated);
}
void launcher_menu_layer_get_selection_vertical_range(const LauncherMenuLayer *launcher_menu_layer,
GRangeVertical *vertical_range_out) {
if (!launcher_menu_layer || !vertical_range_out) {
return;
}
GRect selection_global_rect;
layer_get_global_frame(&launcher_menu_layer->menu_layer.inverter.layer, &selection_global_rect);
*vertical_range_out = (GRangeVertical) {
.origin_y = selection_global_rect.origin.y,
.size_h = selection_global_rect.size.h,
};
}
void launcher_menu_layer_get_selection_state(const LauncherMenuLayer *launcher_menu_layer,
LauncherMenuLayerSelectionState *state_out) {
if (!launcher_menu_layer || !launcher_menu_layer->data_source || !state_out) {
return;
}
const MenuLayer *menu_layer = &launcher_menu_layer->menu_layer;
const ScrollLayer *scroll_layer = &menu_layer->scroll_layer;
*state_out = (LauncherMenuLayerSelectionState) {
.row_index = menu_layer_get_selected_index(menu_layer).row,
// This cast is required because this ScrollLayer function's argument isn't const
.scroll_offset_y = scroll_layer_get_content_offset((ScrollLayer *)scroll_layer).y,
};
}
void launcher_menu_layer_set_selection_animations_enabled(LauncherMenuLayer *launcher_menu_layer,
bool enabled) {
if (!launcher_menu_layer) {
return;
}
launcher_menu_layer->selection_animations_enabled = enabled;
if (enabled) {
const MenuIndex selected_index =
menu_layer_get_selected_index(&launcher_menu_layer->menu_layer);
prv_play_glance_for_row(launcher_menu_layer, selected_index.row);
} else {
launcher_app_glance_service_rewind_current_glance(&launcher_menu_layer->glance_service);
}
}
void launcher_menu_layer_deinit(LauncherMenuLayer *launcher_menu_layer) {
if (!launcher_menu_layer) {
return;
}
launcher_app_glance_service_deinit(&launcher_menu_layer->glance_service);
menu_layer_deinit(&launcher_menu_layer->menu_layer);
#if PBL_ROUND
layer_deinit(&launcher_menu_layer->up_arrow_layer);
layer_deinit(&launcher_menu_layer->down_arrow_layer);
#endif
layer_deinit(&launcher_menu_layer->container_layer);
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "launcher_app_glance_service.h"
#include "process_management/app_menu_data_source.h"
#if PLATFORM_ROBERT
#define LAUNCHER_MENU_LAYER_TITLE_FONT (FONT_KEY_GOTHIC_24_BOLD)
#define LAUNCHER_MENU_LAYER_SUBTITLE_FONT (FONT_KEY_GOTHIC_18)
#else
#define LAUNCHER_MENU_LAYER_TITLE_FONT (FONT_KEY_GOTHIC_18_BOLD)
#define LAUNCHER_MENU_LAYER_SUBTITLE_FONT (FONT_KEY_GOTHIC_14)
#endif
#define LAUNCHER_MENU_LAYER_SELECTION_BACKGROUND_COLOR (PBL_IF_COLOR_ELSE(GColorVividCerulean, \
GColorBlack))
typedef struct LauncherMenuLayer {
Layer container_layer;
MenuLayer menu_layer;
#if PBL_ROUND
Layer up_arrow_layer;
Layer down_arrow_layer;
#endif
GFont title_font;
GFont subtitle_font;
AppMenuDataSource *data_source;
LauncherAppGlanceService glance_service;
bool selection_animations_enabled;
AppInstallId app_to_launch_after_next_render;
} LauncherMenuLayer;
typedef struct LauncherMenuLayerSelectionState {
int16_t scroll_offset_y;
uint16_t row_index;
} LauncherMenuLayerSelectionState;
void launcher_menu_layer_init(LauncherMenuLayer *launcher_menu_layer,
AppMenuDataSource *data_source);
Layer *launcher_menu_layer_get_layer(LauncherMenuLayer *launcher_menu_layer);
void launcher_menu_layer_set_click_config_onto_window(LauncherMenuLayer *launcher_menu_layer,
Window *window);
void launcher_menu_layer_reload_data(LauncherMenuLayer *launcher_menu_layer);
void launcher_menu_layer_set_selection_state(LauncherMenuLayer *launcher_menu_layer,
const LauncherMenuLayerSelectionState *new_state);
void launcher_menu_layer_get_selection_state(const LauncherMenuLayer *launcher_menu_layer,
LauncherMenuLayerSelectionState *state_out);
void launcher_menu_layer_get_selection_vertical_range(const LauncherMenuLayer *launcher_menu_layer,
GRangeVertical *vertical_range_out);
void launcher_menu_layer_set_selection_animations_enabled(LauncherMenuLayer *launcher_menu_layer,
bool enabled);
void launcher_menu_layer_deinit(LauncherMenuLayer *launcher_menu_layer);

View file

@ -0,0 +1,36 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "util/math.h"
#if PLATFORM_ROBERT
#define LAUNCHER_MENU_LAYER_CELL_RECT_CELL_HEIGHT (53)
#else
#define LAUNCHER_MENU_LAYER_CELL_RECT_CELL_HEIGHT (42)
#endif
#define LAUNCHER_MENU_LAYER_CELL_ROUND_FOCUSED_CELL_HEIGHT (52)
#define LAUNCHER_MENU_LAYER_CELL_ROUND_UNFOCUSED_CELL_HEIGHT (38)
#if PBL_ROUND
//! Two "unfocused" cells above and below one centered "focused" cell
#define LAUNCHER_MENU_LAYER_NUM_VISIBLE_ROWS (3)
#else
#define LAUNCHER_MENU_LAYER_NUM_VISIBLE_ROWS \
(DIVIDE_CEIL(DISP_ROWS, LAUNCHER_MENU_LAYER_CELL_RECT_CELL_HEIGHT))
#endif

View file

@ -0,0 +1,29 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#if PLATFORM_TINTIN
#include "legacy/launcher_app.h"
#else
#include "default/launcher_app.h"
#endif
#include "process_management/pebble_process_md.h"
#define RETURN_TIMEOUT_TICKS (5 * RTC_TICKS_HZ)
const PebbleProcessMd* launcher_menu_app_get_app_info(void);

View file

@ -0,0 +1,313 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "launcher_app.h"
#include "applib/app.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/ui.h"
#include "applib/legacy2/ui/menu_layer_legacy2.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_manager.h"
#include "process_management/app_menu_data_source.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource.h"
#include "resource/resource_ids.auto.h"
#include "shell/normal/app_idle_timeout.h"
#include "services/normal/notifications/do_not_disturb.h"
#include "services/normal/notifications/alerts_private.h"
#include "applib/ui/kino/kino_layer.h"
#include "system/passert.h"
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef struct LauncherMenuData {
Window window;
StatusBarLayer status_bar;
Layer status_bar_icons_layer;
MenuLayer menu_layer;
AppMenuDataSource data_source;
EventServiceInfo battery_state_event_info;
EventServiceInfo do_not_disturb_event_info;
EventServiceInfo pebble_app_event_info;
KinoLayer connectivity_icon;
uint32_t connectivity_icon_id;
KinoLayer battery_icon;
uint32_t battery_icon_id;
} LauncherMenuData;
typedef struct LauncherMenuPersistedData {
int scroll_offset_y;
int menu_index_row;
bool valid;
RtcTicks leave_time;
} LauncherMenuPersistedData;
static LauncherMenuPersistedData s_launcher_menu_persisted_data;
///////////////////
// Status Bar
static bool prv_is_pebble_app_connected(void) {
return (comm_session_get_system_session() != NULL);
}
static uint32_t prv_get_resource_id_for_battery_charge_state(BatteryChargeState charge_state) {
const uint32_t battery_base_resource_id = (charge_state.is_charging || charge_state.is_plugged)
? RESOURCE_ID_TINTIN_LAUNCHER_CHARGING_5_PERCENT
: RESOURCE_ID_TINTIN_LAUNCHER_BATTERY_5_PERCENT;
if (charge_state.charge_percent <= 100) {
return battery_base_resource_id + (charge_state.charge_percent / 10);
} else {
WTF;
}
}
static void prv_reload_status_bar_icons(LauncherMenuData *data) {
// Draw airplane mode, do not disturb, or silent status icon.
AlertMask alert_mask = alerts_get_mask();
// Get the connectivity ResourceId
uint32_t new_connectivity_icon_id = RESOURCE_ID_INVALID;
if (bt_ctl_is_airplane_mode_on()) {
new_connectivity_icon_id = RESOURCE_ID_CONNECTIVITY_BLUETOOTH_AIRPLANE_MODE;
} else if (do_not_disturb_is_active()) {
new_connectivity_icon_id = RESOURCE_ID_CONNECTIVITY_BLUETOOTH_DND;
} else if (!prv_is_pebble_app_connected()) {
new_connectivity_icon_id = RESOURCE_ID_CONNECTIVITY_BLUETOOTH_DISCONNECTED;
} else if (alert_mask != AlertMaskAllOn) {
if (alert_mask == AlertMaskPhoneCalls) {
new_connectivity_icon_id = RESOURCE_ID_CONNECTIVITY_BLUETOOTH_CALLS_ONLY;
}
} else if (prv_is_pebble_app_connected()) {
new_connectivity_icon_id = RESOURCE_ID_CONNECTIVITY_BLUETOOTH_CONNECTED;
// probably need an All Muted icon here
}
// replace the image if the connectivity ResourceId has changed
if (data->connectivity_icon_id != new_connectivity_icon_id) {
data->connectivity_icon_id = new_connectivity_icon_id;
kino_layer_set_reel_with_resource(&data->connectivity_icon, data->connectivity_icon_id);
}
// Get the connectivity ResourceId
const uint32_t new_battery_icon_id =
prv_get_resource_id_for_battery_charge_state(battery_get_charge_state());
// replace the image if the battery ResourceId has changed
if (data->battery_icon_id != new_battery_icon_id) {
data->battery_icon_id = new_battery_icon_id;
kino_layer_set_reel_with_resource(&data->battery_icon, data->battery_icon_id);
}
}
///////////////////
// Events
static void prv_event_handler(PebbleEvent *e, void *context) {
LauncherMenuData *data = (LauncherMenuData *) context;
prv_reload_status_bar_icons(data);
}
static void prv_subscribe_to_event(EventServiceInfo *result,
PebbleEventType type,
void *callback_context) {
*result = (EventServiceInfo) {
.type = type,
.handler = prv_event_handler,
.context = callback_context,
};
event_service_client_subscribe(result);
}
///////////////////
// AppMenuDataSource callbacks
static bool prv_app_filter_callback(struct AppMenuDataSource * const source,
AppInstallEntry *entry) {
if (app_install_entry_is_watchface(entry)
|| app_install_entry_is_hidden((entry))) {
return false; // Skip watchfaces and hidden apps
}
return true;
}
static void prv_data_changed(void *context) {
LauncherMenuData *data = context;
menu_layer_reload_data(&data->menu_layer);
}
//////////////
// MenuLayer callbacks
static void select_callback(MenuLayer *menu_layer, MenuIndex *cell_index,
LauncherMenuData *data) {
AppMenuNode *node = app_menu_data_source_get_node_at_index(&data->data_source, cell_index->row);
app_manager_put_launch_app_event(&(AppLaunchEventConfig) {
.id = node->install_id,
.common.reason = APP_LAUNCH_USER,
.common.button = BUTTON_ID_SELECT,
});
}
static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index,
LauncherMenuData *data) {
return app_menu_data_source_get_count(&data->data_source);
}
static void draw_row_callback(GContext *ctx, Layer *cell_layer, MenuIndex *cell_index,
LauncherMenuData *data) {
app_menu_data_source_draw_row(&data->data_source, ctx, cell_layer, cell_index);
}
///////////////////
// Window callbacks
static void prv_window_load(Window *window) {
LauncherMenuData *data = window_get_user_data(window);
GRect bounds = window->layer.bounds;
status_bar_layer_init(&data->status_bar);
status_bar_layer_set_colors(&data->status_bar, GColorBlack, GColorWhite);
layer_add_child(&window->layer, status_bar_layer_get_layer(&data->status_bar));
static const int kino_width = 20;
static const int kino_padding = 6;
kino_layer_init(&data->connectivity_icon, &GRect(kino_padding, 0,
kino_width, STATUS_BAR_LAYER_HEIGHT));
kino_layer_set_alignment(&data->connectivity_icon, GAlignLeft);
layer_add_child(&window->layer, kino_layer_get_layer(&data->connectivity_icon));
kino_layer_init(&data->battery_icon, &GRect(DISP_COLS - kino_width - kino_padding, 0,
kino_width, STATUS_BAR_LAYER_HEIGHT));
kino_layer_set_alignment(&data->battery_icon, GAlignRight);
layer_add_child(&window->layer, kino_layer_get_layer(&data->battery_icon));
prv_reload_status_bar_icons(data);
bounds = grect_inset(bounds, GEdgeInsets(STATUS_BAR_LAYER_HEIGHT, 0, 0, 0));
MenuLayer *menu_layer = &data->menu_layer;
menu_layer_init(menu_layer, &bounds);
app_menu_data_source_init(&data->data_source, &(AppMenuDataSourceCallbacks) {
.changed = prv_data_changed,
.filter = prv_app_filter_callback,
}, data);
app_menu_data_source_enable_icons(&data->data_source,
RESOURCE_ID_MENU_LAYER_GENERIC_WATCHAPP_ICON);
menu_layer_set_callbacks(menu_layer, data, &(MenuLayerCallbacks) {
.get_num_rows = (MenuLayerGetNumberOfRowsInSectionsCallback) get_num_rows_callback,
.draw_row = (MenuLayerDrawRowCallback) draw_row_callback,
.select_click = (MenuLayerSelectCallback) select_callback,
});
menu_layer_set_click_config_onto_window(menu_layer, window);
layer_add_child(&window->layer, menu_layer_get_layer(menu_layer));
scroll_layer_set_shadow_hidden(&data->menu_layer.scroll_layer, true);
if (s_launcher_menu_persisted_data.valid) {
// If we have a saved state, reload it.
menu_layer_set_selected_index(&data->menu_layer,
MenuIndex(0, s_launcher_menu_persisted_data.menu_index_row),
MenuRowAlignNone,
false);
scroll_layer_set_content_offset(&data->menu_layer.scroll_layer,
GPoint(0, s_launcher_menu_persisted_data.scroll_offset_y),
false);
} else {
// If we are resetting the launcher, select the second entry (Settings is at the top)
menu_layer_set_selected_index(&data->menu_layer, MenuIndex(0, 1), MenuRowAlignNone, false);
}
prv_subscribe_to_event(&data->battery_state_event_info, PEBBLE_BATTERY_STATE_CHANGE_EVENT, data);
prv_subscribe_to_event(&data->do_not_disturb_event_info, PEBBLE_DO_NOT_DISTURB_EVENT, data);
prv_subscribe_to_event(&data->pebble_app_event_info, PEBBLE_COMM_SESSION_EVENT, data);
}
static void prv_window_unload(Window *window) {
LauncherMenuData *data = window_get_user_data(window);
kino_layer_deinit(&data->connectivity_icon);
kino_layer_deinit(&data->battery_icon);
// Save the current state of the menu so we can restore it later.
s_launcher_menu_persisted_data = (LauncherMenuPersistedData) {
.valid = true,
.scroll_offset_y = scroll_layer_get_content_offset(&data->menu_layer.scroll_layer).y,
.menu_index_row = menu_layer_get_selected_index(&data->menu_layer).row,
.leave_time = rtc_get_ticks(),
};
menu_layer_deinit(&data->menu_layer);
app_menu_data_source_deinit(&data->data_source);
}
static void launcher_menu_push_window(void) {
LauncherMenuData *data = app_zalloc(sizeof(LauncherMenuData));
app_state_set_user_data(data);
// Push launcher menu window:
Window *window = &data->window;
window_init(window, WINDOW_NAME("Launcher Menu"));
window_set_user_data(window, data);
window_set_window_handlers(window, &(WindowHandlers) {
.load = prv_window_load,
.unload = prv_window_unload,
});
const bool animated = false;
app_window_stack_push(window, animated);
}
////////////////////
// App boilerplate
static void s_main(void) {
const LauncherMenuArgs *args = (const LauncherMenuArgs *) app_manager_get_task_context()->args;
if (args && args->reset_scroll) {
if ((s_launcher_menu_persisted_data.leave_time + RETURN_TIMEOUT_TICKS) <= rtc_get_ticks()) {
s_launcher_menu_persisted_data.valid = false;
}
}
launcher_menu_push_window();
app_idle_timeout_start();
app_event_loop();
}
const PebbleProcessMd* launcher_menu_app_get_app_info() {
static const PebbleProcessMdSystem s_launcher_menu_app_info = {
.common = {
.main_func = s_main,
// UUID: dec0424c-0625-4878-b1f2-147e57e83688
.uuid = {0xde, 0xc0, 0x42, 0x4c, 0x06, 0x25, 0x48, 0x78,
0xb1, 0xf2, 0x14, 0x7e, 0x57, 0xe8, 0x36, 0x88},
.visibility = ProcessVisibilityHidden
},
.name = "Launcher",
};
return (const PebbleProcessMd*) &s_launcher_menu_app_info;
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "../launcher_app.h"
#include <stdbool.h>
typedef struct LauncherMenuArgs {
bool reset_scroll;
} LauncherMenuArgs;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* music_app_get_info();

View file

@ -0,0 +1,818 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "notifications_app.h"
#include <stdio.h>
#include <time.h>
#include "applib/app.h"
#include "applib/app_exit_reason.h"
#include "applib/preferred_content_size.h"
#include "applib/fonts/fonts.h"
#include "applib/graphics/gdraw_command_image.h"
#include "applib/graphics/gdraw_command_list.h"
#include "applib/graphics/graphics.h"
#include "applib/ui/dialogs/actionable_dialog.h"
#include "applib/ui/dialogs/simple_dialog.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/menu_cell_layer.h"
#include "applib/ui/ui.h"
#include "applib/ui/window_stack_private.h"
#include "kernel/pbl_malloc.h"
#include "kernel/ui/system_icons.h"
#include "popups/notifications/notification_window.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/blob_db/pin_db.h"
#include "services/normal/notifications/notification_storage.h"
#include "services/normal/timeline/notification_layout.h"
#include "shell/system_theme.h"
#include "system/passert.h"
#include "util/date.h"
#include "util/list.h"
#include "util/string.h"
#if !TINTIN_FORCE_FIT
typedef struct LoadedNotificationNode {
ListNode node;
TimelineItem notification;
GDrawCommandImage *icon;
bool icon_is_default;
} LoadedNotificationNode;
typedef struct NotificationNode {
ListNode node;
Uuid id;
} NotificationNode;
typedef struct NotificationsData {
Window window;
MenuLayer menu_layer;
TextLayer text_layer;
NotificationNode *notification_list;
LoadedNotificationNode *loaded_notification_list;
EventServiceInfo notification_event_info;
ActionableDialog *actionable_dialog;
#if PBL_ROUND
StatusBarLayer status_bar_layer;
#endif
} NotificationsData;
static NotificationsData *s_data = NULL;
static const unsigned int MAX_ACTIVE_NOTIFICATIONS = 6;
static bool prv_loaded_notification_list_filter_cb(ListNode *node, void *data) {
LoadedNotificationNode *loaded_notification = (LoadedNotificationNode *)node;
Uuid *id = data;
return uuid_equal(&loaded_notification->notification.header.id, id);
}
static bool prv_notification_list_filter_cb(ListNode *node, void *data) {
NotificationNode *notification = (NotificationNode *)node;
Uuid *id = data;
return uuid_equal(&notification->id, id);
}
static NotificationNode *prv_find_notification(NotificationNode *list, Uuid *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);
}
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);
new_node->id = *id;
*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) {
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);
return node;
}
static void prv_remove_notification(NotificationsData *data, Uuid *id) {
prv_notification_list_remove_notification_by_id(&data->notification_list, id);
}
static bool prv_notif_iterator_callback(void *data, SerializedTimelineItemHeader *header) {
return (prv_add_notification(data, &header->common.id) != NULL);
}
static void prv_load_notification_storage(NotificationsData *data) {
notification_storage_iterate(&prv_notif_iterator_callback, 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);
app_free(node);
}
}
static void prv_unload_loaded_notification(LoadedNotificationNode *loaded_notif) {
timeline_item_free_allocated_buffer(&loaded_notif->notification);
gdraw_command_image_destroy(loaded_notif->icon);
app_free(loaded_notif);
}
static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item(
LoadedNotificationNode **loaded_list, NotificationNode *node) {
if (node == NULL) {
return NULL;
}
LoadedNotificationNode *loaded_node = prv_find_loaded_notification(*loaded_list, &node->id);
if (loaded_node) {
return loaded_node;
}
// 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);
prv_unload_loaded_notification(old_node);
}
// load the notification
TimelineItem notification;
if (!notification_storage_get(&node->id, &notification)) {
return NULL;
}
// track the loaded notification
loaded_node = app_malloc_check(sizeof(LoadedNotificationNode));
list_init((ListNode*) loaded_node);
loaded_node->notification = notification;
TimelineResourceId timeline_res_id = attribute_get_uint32(&notification.attr_list,
AttributeIdIconTiny,
NOTIF_FALLBACK_ICON);
// Read the associated pin's app id
TimelineItem pin;
if (timeline_resources_is_system(timeline_res_id) ||
pin_db_read_item_header(&pin, &notification.header.parent_id) != S_SUCCESS) {
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
};
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,
icon_res_info.res_id);
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);
return loaded_node;
}
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);
prv_unload_loaded_notification(node);
}
}
// Return true if successful
static bool prv_push_notification_window(NotificationsData *data) {
notification_window_init(false /*is_modal*/);
// Bail if a notification came in ahead of us and created a modal window
// before we had a chance to react to the select button event.
if (notification_window_is_modal()) {
return false;
}
// 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);
while (node) {
notification_window_add_notification_by_id(&node->id);
node = (NotificationNode*)list_get_prev(&node->node);
}
notification_window_show();
return true;
}
///////////////////
// Confirm Dialog
static void prv_dialog_unloaded(void *context) {
NotificationsData *data = context;
data->actionable_dialog = NULL;
}
static void prv_confirmed_handler(ClickRecognizerRef recognizer, void *context) {
NotificationsData *data = context;
notification_storage_reset_and_init();
prv_loaded_notification_list_deinit(data->loaded_notification_list);
data->loaded_notification_list = NULL;
prv_notification_list_deinit(data->notification_list);
data->notification_list = NULL;
prv_load_notification_storage(data);
actionable_dialog_pop(data->actionable_dialog);
// Create and display DONE dialog
SimpleDialog *confirmation_dialog = simple_dialog_create("Notifications Cleared");
Dialog *dialog = simple_dialog_get_dialog(confirmation_dialog);
dialog_set_text(dialog, i18n_get("Done", data));
dialog_set_icon(dialog, RESOURCE_ID_RESULT_SHREDDED_LARGE);
static const uint32_t DIALOG_TIMEOUT = 2000;
dialog_set_timeout(dialog, DIALOG_TIMEOUT);
// Set the app exit reason so we will go to the watchface upon exit
app_exit_reason_set(APP_EXIT_ACTION_PERFORMED_SUCCESSFULLY);
// Pop all windows so we'll soon exit the app
app_window_stack_pop_all(true /* animated */);
// Immediately push this result dialog so it's the last thing we see before exiting
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);
window_set_click_context(BUTTON_ID_SELECT, data);
}
static void prv_settings_clear_history_window_push(NotificationsData *data) {
ActionableDialog *actionable_dialog = actionable_dialog_create("Clear Notifications");
actionable_dialog_set_click_config_provider(actionable_dialog, prv_dialog_click_config);
actionable_dialog_set_action_bar_type(actionable_dialog, DialogActionBarConfirm, NULL);
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,
};
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);
app_actionable_dialog_push(actionable_dialog);
data->actionable_dialog = actionable_dialog;
}
static GColor prv_invert_bw_color(GColor color) {
if (gcolor_equal(color, GColorBlack)) {
return GColorWhite;
} else if (gcolor_equal(color, GColorWhite)) {
return GColorBlack;
}
return color;
}
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,
prv_invert_bw_color(gdraw_command_get_stroke_color((GDrawCommand *)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,
};
gdraw_command_image_draw_processed(ctx, image, offset, &processor);
}
//////////////
// MenuLayer callbacks
static const uint8_t BAR_PX = 9;
static const uint8_t BAR_SELECTED_PX = 12;
static void prv_draw_notification_cell_rect(GContext *ctx, const Layer *cell_layer,
const char *title, const char *subtitle,
GDrawCommandImage *icon) {
const GRect cell_layer_bounds = cell_layer->bounds;
const GSize icon_size = gdraw_command_image_get_bounds_size(icon);
const int16_t icon_left_margin = menu_cell_basic_horizontal_inset();
if (icon) {
void (*draw_func)(GContext *, GDrawCommandImage *, GPoint) = gdraw_command_image_draw;
#if PBL_BW
if (menu_cell_layer_is_highlighted(cell_layer)) {
draw_func = prv_draw_pdc_bw_inverted;
}
#endif
// Inset the draw box from the left to leave some margin on the icon's left side
GRect box = cell_layer_bounds;
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_align(&icon_rect, &box, GAlignLeft, false /* clip */);
draw_func(ctx, icon, icon_rect.origin);
}
// 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 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);
menu_cell_basic_draw_custom(ctx, cell_layer, title_font, title, NULL /* value_font */,
NULL /* value */, subtitle_font, subtitle, NULL /* icon */,
false /* icon_on_right */, GTextOverflowModeTrailingEllipsis);
// Restore the cell layer's bounds
mutable_cell_layer->bounds = cell_layer_bounds;
}
//! 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) {
if (!text) {
return 0;
}
GRect text_box = *outer_box;
text_box.size.h = fonts_get_font_height(font);
grect_align(&text_box, outer_box, align, true);
graphics_draw_text(ctx, text, font, text_box, GTextOverflowModeTrailingEllipsis,
GTextAlignmentCenter, NULL);
return text_box.size.h;
}
//! box is passed as a pointer to save stack space
//! after this call, box will point to the GRect where
//! the notification title was drawn
void prv_draw_notification_cell_round(GContext *ctx, const Layer *cell_layer, GRect *box,
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)};
grect_align(&icon_rect, box, GAlignTop, true);
icon_rect.origin.y += 4;
gdraw_command_image_draw(ctx, icon, icon_rect.origin);
// more box by icon + some margin
const int16_t icon_space = icon_rect.origin.y + icon_rect.size.h - 12;
// manually inset to save stack space, instead of using grect_inset
box->origin.y += icon_space;
box->size.h -= icon_space;
}
// hack: compensate for text placement inside a rect
box->origin.y -= 4;
if (subtitle) {
box->size.h -= prv_draw_centered_text_line_in(ctx, subtitle_font, box, subtitle,
GAlignBottom);
}
if (title) {
prv_draw_centered_text_line_in(ctx, title_font, box, title, GAlignCenter);
}
}
static void prv_draw_notification_cell_round_selected(GContext *ctx, const Layer *cell_layer,
const char *title, const char *subtitle,
GDrawCommandImage *icon) {
// as measured from the design specs
const int inset = 8;
GRect frame = cell_layer->bounds;
// manually inset the frame to save stack space, instead of using grect_inset
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);
prv_draw_notification_cell_round(ctx, cell_layer, &frame, title_font, title, subtitle_font,
subtitle, icon);
}
static void prv_draw_notification_cell_round_unselected(GContext *ctx, const Layer *cell_layer,
const char *title, const char *subtitle,
GDrawCommandImage *icon) {
// as measured from the design specs
const int horizontal_inset = MENU_CELL_ROUND_UNFOCUSED_HORIZONTAL_INSET;
const int top_inset = 2;
GRect frame = cell_layer->bounds;
// manually inset the frame to save stack space, instead of using grect_inset
frame.origin.x += horizontal_inset;
frame.size.w -= horizontal_inset * 2;
frame.origin.y += top_inset;
frame.size.h -= top_inset;
// Using TextStyleFont_Header here is a little bit of a hack to achieve Gothic 18 Bold on
// Spalding's default content size (medium) while still being a little robust for any future round
// watches that have a default content size larger than medium
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);
}
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)) {
// Clear All button selected
prv_settings_clear_history_window_push(notifications_data);
return;
}
// 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);
if (!node) {
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
// before we had a chance to react to the select button event.
return;
}
const bool animated = false;
notification_window_focus_notification(&node->id, animated);
}
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;
// 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;
}
static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell_index,
void *data) {
#if PBL_ROUND
MenuIndex selected_index = menu_layer_get_selected_index(menu_layer);
bool is_selected = menu_index_compare(cell_index, &selected_index) == 0;
if (is_selected) {
return MENU_CELL_ROUND_FOCUSED_TALL_CELL_HEIGHT;
}
#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];
}
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);
#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)) {
draw_cell = prv_draw_notification_cell_round_unselected;
}
#endif
bool first_row = (cell_index->row == 0);
// Test if there are any notifications in the list.
if (first_row) {
// Draw "Clear all" box and exit
#if PBL_ROUND
draw_cell(ctx, cell_layer, i18n_get("Clear All", data), NULL, NULL);
#else
const GFont font = system_theme_get_font_for_default_size(TextStyleFont_MenuCellTitle);
GRect box = cell_layer->bounds;
box.origin.y += 6;
graphics_draw_text(ctx, i18n_get("Clear All", data), font, box,
GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
#endif
return;
}
// 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);
if (!node) {
return;
}
LoadedNotificationNode *loaded_node = prv_loaded_notification_list_load_item(
&notifications_data->loaded_notification_list, node);
if (!loaded_node) {
return;
}
TimelineItem *notification = &loaded_node->notification;
const char *title = attribute_get_string(&notification->attr_list, AttributeIdTitle, "");
const char *subtitle = attribute_get_string(&notification->attr_list, AttributeIdSubtitle, "");
const char *app_name = attribute_get_string(&notification->attr_list, AttributeIdAppName, "");
const char *body = attribute_get_string(&notification->attr_list, AttributeIdBody, "");
// 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) {
title = app_name;
}
if (!IS_EMPTY_STRING(title) && !IS_EMPTY_STRING(subtitle)) {
// we got a title & subtitle, we're done
} else if (IS_EMPTY_STRING(title) && IS_EMPTY_STRING(subtitle)) {
// we got neither, use the body
if (IS_EMPTY_STRING(body)) {
// we're screwed... empty message
title = "[Empty]";
} else {
// try to show as much content as possible in title + subtitle
title = body;
subtitle = strchr(body, '\n'); // NULL handled gracefully downstream
}
} else if (IS_EMPTY_STRING(title)) {
// no title, but yes subtitle.
title = subtitle;
subtitle = body;
} else if (IS_EMPTY_STRING(subtitle)) {
// no subtitle, but yes title
subtitle = body;
} else {
WTF;
}
draw_cell(ctx, cell_layer, title, subtitle, loaded_node->icon);
}
// Display the appropriate layer
static void prv_update_text_layer_visibility(NotificationsData *data) {
NotificationNode *node = data->notification_list;
// Toggle which layer is visible
if (node == NULL) {
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);
}
}
static void prv_handle_notification_removed(Uuid *id) {
prv_remove_notification(s_data, id);
app_notification_window_remove_notification_by_id(id);
}
static void prv_handle_notification_acted_upon(Uuid *id) {
app_notification_window_handle_notification_acted_upon_by_id(id);
}
static void prv_handle_notification_added(Uuid *id) {
TimelineItem notification;
if (!notification_storage_get(id, &notification)) {
return;
}
prv_add_notification(s_data, id);
// 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.
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) {
case NotificationAdded:
prv_handle_notification_added(id);
break;
case NotificationRemoved:
prv_handle_notification_removed(id);
break;
case NotificationActedUpon:
prv_handle_notification_acted_upon(id);
break;
default:
break;
// Not implemented
}
menu_layer_reload_data(&s_data->menu_layer);
prv_update_text_layer_visibility(s_data);
}
// we don't handle reminders within the notifications app
}
///////////////////
// Window callbacks
static void prv_window_appear(Window *window) {
NotificationsData *data = window_get_user_data(window);
prv_update_text_layer_visibility(data);
}
static void prv_window_disappear(Window *window) {
NotificationsData *data = window_get_user_data(window);
prv_loaded_notification_list_deinit(data->loaded_notification_list);
data->loaded_notification_list = NULL;
}
static void prv_window_load(Window *window) {
NotificationsData *data = window_get_user_data(window);
MenuLayer *menu_layer = &data->menu_layer;
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_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_click_config_onto_window(menu_layer, window);
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);
layer_add_child(&window->layer, text_layer_get_layer(text_layer));
#if PBL_ROUND
GColor bg_color = GColorClear;
GColor fg_color = GColorBlack;
StatusBarLayer *status_bar = &data->status_bar_layer;
status_bar_layer_init(status_bar);
status_bar_layer_set_colors(status_bar, bg_color, fg_color);
layer_add_child(&window->layer, &status_bar->layer);
#endif
menu_layer_set_selected_index(menu_layer, MenuIndex(0, 1),
PBL_IF_RECT_ELSE(MenuRowAlignNone, MenuRowAlignCenter), false);
}
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,
});
const bool animated = true;
app_window_stack_push(window, animated);
}
////////////////////
// App boilerplate
static void prv_handle_init(void) {
NotificationsData *data = s_data = app_zalloc_check(sizeof(NotificationsData));
app_state_set_user_data(data);
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_push_window(data);
}
static void prv_handle_deinit(void) {
NotificationsData *data = app_state_get_user_data();
#if PBL_ROUND
status_bar_layer_deinit(&data->status_bar_layer);
#endif
menu_layer_deinit(&data->menu_layer);
event_service_client_unsubscribe(&data->notification_event_info);
prv_loaded_notification_list_deinit(data->loaded_notification_list);
prv_notification_list_deinit(data->notification_list);
i18n_free_all(data);
app_free(data);
s_data = NULL;
}
static void prv_s_main(void) {
prv_handle_init();
app_event_loop();
prv_handle_deinit();
}
#else
static void prv_s_main(void) {}
#endif
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,
};
return (const PebbleProcessMd*) &s_app_md;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* notifications_app_get_info();

View file

@ -0,0 +1,316 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "reminder_app.h"
#include "reminder_app_prefs.h"
#include "applib/app.h"
#include "applib/ui/dialogs/simple_dialog.h"
#include "applib/ui/ui.h"
#include "applib/voice/transcription_dialog.h"
#include "applib/voice/voice_window.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "resource/timeline_resource_ids.auto.h"
#include "services/common/bluetooth/bluetooth_persistent_storage.h"
#include "services/common/clock.h"
#include "services/common/comm_session/session_remote_version.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/timeline/timeline.h"
#include "services/normal/blob_db/watch_app_prefs_db.h"
#include "util/time/time.h"
#include "system/logging.h"
typedef enum ReminderAppUIState {
ReminderAppUIState_Start,
ReminderAppUIState_WaitForDictationEvent,
ReminderAppUIState_Exit,
} ReminderAppUIState;
typedef struct ReminderAppData {
Window window;
VoiceWindow *voice_window;
EventServiceInfo event_service_info;
TranscriptionDialog transcription_dialog;
char *dialog_text;
char *reminder_str;
time_t timestamp;
ReminderAppUIState ui_state;
} ReminderAppData;
static void prv_create_reminder(ReminderAppData *data) {
AttributeList pin_attr_list = {0};
attribute_list_add_uint32(&pin_attr_list, AttributeIdIconTiny,
TIMELINE_RESOURCE_NOTIFICATION_REMINDER);
attribute_list_add_cstring(&pin_attr_list, AttributeIdTitle, data->reminder_str);
attribute_list_add_uint8(&pin_attr_list, AttributeIdBgColor, GColorChromeYellowARGB8);
AttributeList completed_attr_list = {0};
attribute_list_add_cstring(&completed_attr_list, AttributeIdTitle,
i18n_get("Completed", &pin_attr_list));
AttributeList postpone_attr_list = {0};
attribute_list_add_cstring(&postpone_attr_list, AttributeIdTitle,
i18n_get("Postpone", &pin_attr_list));
AttributeList remove_attr_list = {0};
attribute_list_add_cstring(&remove_attr_list, AttributeIdTitle,
i18n_get("Remove", &pin_attr_list));
const int num_actions = 3;
TimelineItemActionGroup action_group = {
.num_actions = num_actions,
.actions = (TimelineItemAction[]) {
{
.id = 0,
.type = TimelineItemActionTypeComplete,
.attr_list = completed_attr_list,
},
{
.id = 1,
.type = TimelineItemActionTypePostpone,
.attr_list = postpone_attr_list,
},
{
.id = 2,
.type = TimelineItemActionTypeRemoteRemove,
.attr_list = remove_attr_list,
}
},
};
TimelineItem *item = timeline_item_create_with_attributes(data->timestamp,
0, // duration
TimelineItemTypePin,
LayoutIdGeneric,
&pin_attr_list,
&action_group);
item->header.from_watch = true;
item->header.parent_id = (Uuid)UUID_REMINDERS_DATA_SOURCE;
timeline_add(item);
// Tweak the item before adding the reminder
item->header.parent_id = item->header.id;
uuid_generate(&item->header.id);
item->header.type = TimelineItemTypeReminder;
item->header.layout = LayoutIdReminder;
reminders_insert(item);
i18n_free_all(&pin_attr_list);
attribute_list_destroy_list(&pin_attr_list);
attribute_list_destroy_list(&completed_attr_list);
attribute_list_destroy_list(&postpone_attr_list);
attribute_list_destroy_list(&remove_attr_list);
timeline_item_destroy(item);
}
static void prv_push_success_dialog(void) {
SimpleDialog *simple_dialog = simple_dialog_create("Reminder Added");
Dialog *dialog = simple_dialog_get_dialog(simple_dialog);
dialog_set_text(dialog, i18n_get("Added", dialog));
dialog_set_icon(dialog, RESOURCE_ID_GENERIC_REMINDER_LARGE);
dialog_set_background_color(dialog, GColorChromeYellow);
dialog_set_timeout(dialog, DIALOG_TIMEOUT_DEFAULT);
app_simple_dialog_push(simple_dialog);
i18n_free_all(dialog);
}
static void prv_confirm_cb(void *context) {
ReminderAppData *data = context;
data->ui_state = ReminderAppUIState_Exit;
prv_create_reminder(data);
prv_push_success_dialog();
}
static void prv_push_transcription_dialog(ReminderAppData *data) {
TranscriptionDialog *transcription_dialog = &data->transcription_dialog;
transcription_dialog_init(transcription_dialog);
transcription_dialog_update_text(transcription_dialog,
data->dialog_text,
strlen(data->dialog_text));
transcription_dialog_set_callback(transcription_dialog, prv_confirm_cb, data);
Dialog *dialog = expandable_dialog_get_dialog((ExpandableDialog *)transcription_dialog);
dialog_set_destroy_on_pop(dialog, false /* free_on_pop */);
app_transcription_dialog_push(transcription_dialog);
}
static void prv_build_transcription_dialog_text(ReminderAppData *data) {
if (data->dialog_text) {
app_free(data->dialog_text);
}
const size_t sentence_len = strlen(data->reminder_str);
const size_t date_time_len = 32; // "September 19th 9:05pm", "Yesterday 12:33pm"
const size_t required_buf_size = sentence_len + date_time_len + 2 /* \n\n */ + 1 /* \0 */;
data->dialog_text = app_zalloc_check(required_buf_size);
// The string that is being built below looks something like:
// "Take out the trash"
//
// "Tomorrow 7:00AM"
int buf_space_remaining = required_buf_size - 1 /*for the final \0 */;
strncpy(data->dialog_text, data->reminder_str, sentence_len);
// Having to call MAX everytime is a bit silly, but the strn function expect a size_t (unsigned).
// Calling MAX ensures that a negative value isn't passed in which gets cast to something positive
buf_space_remaining = MAX(buf_space_remaining - sentence_len, 0);
strncat(data->dialog_text, "\n\n", buf_space_remaining);
buf_space_remaining = MAX(buf_space_remaining - 2, 0);
char tmp[date_time_len];
clock_get_friendly_date(tmp, date_time_len, data->timestamp);
strncat(data->dialog_text, tmp, buf_space_remaining);
buf_space_remaining = MAX(buf_space_remaining - strlen(tmp), 0);
strncat(data->dialog_text, " ", buf_space_remaining);
buf_space_remaining = MAX(buf_space_remaining - 1, 0);;
clock_get_time_number(tmp, date_time_len, data->timestamp);
strncat(data->dialog_text, tmp, buf_space_remaining);
buf_space_remaining = MAX(buf_space_remaining - strlen(tmp), 0);
clock_get_time_word(tmp, date_time_len, data->timestamp);
strncat(data->dialog_text, tmp, buf_space_remaining);
}
static void prv_handle_dictation_event(PebbleEvent *e, void *context) {
ReminderAppData *data = context;
const DictationSessionStatus status = e->dictation.result;
if (status == DictationSessionStatusSuccess) {
if (data->reminder_str) {
app_free(data->reminder_str);
}
const size_t reminder_str_len = strlen(e->dictation.text);
data->reminder_str = app_zalloc_check(reminder_str_len + 1 /* \0 */);
strcpy(data->reminder_str, e->dictation.text);
data->reminder_str[reminder_str_len] = '\0';
data->timestamp = e->dictation.timestamp;
if (data->timestamp == 0) {
// If the user didn't specify a time set it to be 1 hour from the current time,
// rounded up to the nearest 15 min.
// Ex: a reminder created at 10:08 AM with no specified time is due at 11:15 AM
time_t utc_sec = rtc_get_time() + SECONDS_PER_HOUR + (15 * SECONDS_PER_MINUTE);
struct tm local_tm;
localtime_r(&utc_sec, &local_tm);
local_tm.tm_min -= (local_tm.tm_min % 15);
local_tm.tm_sec = 0;
data->timestamp = mktime(&local_tm);
}
// If the user doesn't accept the transcription, try again.
data->ui_state = ReminderAppUIState_Start;
prv_build_transcription_dialog_text(data);
prv_push_transcription_dialog(data);
} else {
// Exit immediately because this event may or may not be handled before the main window appears.
data->ui_state = ReminderAppUIState_Exit;
app_window_stack_pop_all(false);
}
}
static void prv_appear(struct Window *window) {
ReminderAppData *data = app_state_get_user_data();
switch (data->ui_state) {
case ReminderAppUIState_Start:
// Start a transcription
data->ui_state = ReminderAppUIState_WaitForDictationEvent;
voice_window_reset(data->voice_window);
voice_window_push(data->voice_window);
break;
case ReminderAppUIState_WaitForDictationEvent:
break;
case ReminderAppUIState_Exit:
app_window_stack_pop_all(false);
break;
default:
WTF;
}
}
static NOINLINE void prv_init(void) {
ReminderAppData *data = app_zalloc_check(sizeof(ReminderAppData));
app_state_set_user_data(data);
data->ui_state = ReminderAppUIState_Start;
// This "background" window is needed because without voice confirmation enabled,
// the voice window pops before we get the event and can push the transcription dialog.
// This means we have no windows for a moment and thus the app deinits.
// This window is now also used to catch a 'back' at the confirmation dialog.
Window *window = &data->window;
window_init(window, WINDOW_NAME("Reminders"));
WindowHandlers handlers = { .appear = prv_appear, };
window_set_window_handlers(window, &handlers);
data->event_service_info = (EventServiceInfo) {
.type = PEBBLE_DICTATION_EVENT,
.handler = prv_handle_dictation_event,
.context = data,
};
event_service_client_subscribe(&data->event_service_info);
data->voice_window = voice_window_create(NULL, 0, VoiceEndpointSessionTypeNLP);
voice_window_set_confirmation_enabled(data->voice_window, false);
// Let the main window manage the voice window
app_window_stack_push(window, false);
}
static void prv_deinit(void) {
ReminderAppData *data = app_state_get_user_data();
voice_window_destroy(data->voice_window);
event_service_client_unsubscribe(&data->event_service_info);
app_free(data->dialog_text);
app_free(data);
}
static void prv_main(void) {
prv_init();
app_event_loop();
prv_deinit();
}
const PebbleProcessMd* reminder_app_get_info(void) {
PebbleProtocolCapabilities capabilities;
bt_persistent_storage_get_cached_system_capabilities(&capabilities);
SerializedReminderAppPrefs *prefs = watch_app_prefs_get_reminder();
const bool is_visible_in_launcher = capabilities.reminders_app_support &&
(prefs ? (prefs->appState == ReminderAppState_Enabled) : false);
task_free(prefs);
static const PebbleProcessMdSystem s_reminder_app_info = {
.common = {
.main_func = prv_main,
.uuid = UUID_REMINDERS_DATA_SOURCE,
},
.name = i18n_noop("Reminder"),
#if CAPABILITY_HAS_APP_GLANCES
.icon_resource_id = RESOURCE_ID_GENERIC_REMINDER_TINY,
#endif
};
return is_visible_in_launcher ? (const PebbleProcessMd *)&s_reminder_app_info : NULL;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* reminder_app_get_info(void);

View file

@ -0,0 +1,32 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "util/attributes.h"
#define PREF_KEY_REMINDER_APP "remindersApp"
typedef enum ReminderAppState {
ReminderAppState_NotEnabled = 0,
ReminderAppState_NotConfigured = 1,
ReminderAppState_Enabled = 2,
ReminderAppStateCount
} ReminderAppState;
typedef struct PACKED SerializedReminderAppPrefs {
uint8_t appState; // actually enum ReminderAppState
} SerializedReminderAppPrefs;

View file

@ -0,0 +1,400 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "send_text.h"
#include "send_text_app_prefs.h"
#include "applib/app.h"
#include "applib/app_exit_reason.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/ui.h"
#include "apps/system_apps/timeline/peek_layer.h"
#include "kernel/pbl_malloc.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/blob_db/contacts_db.h"
#include "services/normal/blob_db/ios_notif_pref_db.h"
#include "services/normal/blob_db/watch_app_prefs_db.h"
#include "services/normal/contacts/contacts.h"
#include "services/normal/notifications/notification_constants.h"
#include "services/normal/send_text_service.h"
#include "services/normal/timeline/timeline.h"
#include "services/normal/timeline/timeline_actions.h"
#include "shell/prefs.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/time/time.h"
#include "util/trig.h"
#include <stdio.h>
#include <string.h>
#define SEND_TEXT_APP_HIGHLIGHT_COLOR PBL_IF_COLOR_ELSE(SMS_REPLY_COLOR, GColorBlack)
typedef int ContactId;
typedef struct {
ListNode node;
ContactId id;
char *name;
char *display_number;
char *number; // Points to a substring within display_number (the part without the ❤)
} ContactNode;
typedef struct SendTextAppData {
Window window;
MenuLayer menu_layer;
PeekLayer no_contacts_layer;
StatusBarLayer status_layer;
ContactNode *contact_list_head;
EventServiceInfo event_service_info;
} SendTextAppData;
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Action menu functions
static void prv_action_menu_did_close(ActionMenu *action_menu, const ActionMenuItem *item,
void *context) {
TimelineItem *timeline_item = context;
timeline_item_destroy(timeline_item);
}
static void prv_action_handle_response(PebbleEvent *e, void *context) {
SendTextAppData *data = context;
if (e->sys_notification.type != NotificationActionResult) {
// Not what we want
return;
}
PebbleSysNotificationActionResult *action_result = e->sys_notification.action_result;
if (action_result == NULL) {
return;
}
// Each action result can only service one response event
event_service_client_unsubscribe(&data->event_service_info);
if (action_result->type == ActionResultTypeSuccess) {
// Set the exit reason as "action performed successfully" so we return to the watchface
// when we remove the window from the stack to exit the app
app_exit_reason_set(APP_EXIT_ACTION_PERFORMED_SUCCESSFULLY);
app_window_stack_remove(&data->window, false);
}
}
static TimelineItem *prv_create_timeline_item(const char *number) {
iOSNotifPrefs *notif_prefs = ios_notif_pref_db_get_prefs((uint8_t *)SEND_TEXT_NOTIF_PREF_KEY,
strlen(SEND_TEXT_NOTIF_PREF_KEY));
if (!notif_prefs) {
return NULL;
}
AttributeList attr_list = {};
attribute_list_add_cstring(&attr_list, AttributeIdSender, number);
TimelineItem *item = timeline_item_create_with_attributes(0, 0,
TimelineItemTypeNotification,
LayoutIdNotification,
&attr_list,
&notif_prefs->action_group);
if (item) {
item->header.id = (Uuid)UUID_SEND_SMS;
item->header.parent_id = (Uuid)UUID_SEND_TEXT_DATA_SOURCE;
}
attribute_list_destroy_list(&attr_list);
ios_notif_pref_db_free_prefs(notif_prefs);
return item;
}
static void prv_open_action_menu(SendTextAppData *data, const char *number) {
TimelineItem *item = prv_create_timeline_item(number);
// This handles the case where item is NULL, so no need to check for that
TimelineItemAction *reply_action = timeline_item_find_action_by_type(
item, TimelineItemActionTypeResponse);
if (!reply_action) {
PBL_LOG(LOG_LEVEL_ERROR, "Not opening response menu - unable to load reply action");
timeline_item_destroy(item);
return;
}
timeline_actions_push_response_menu(item, reply_action, SEND_TEXT_APP_HIGHLIGHT_COLOR,
prv_action_menu_did_close, data->window.parent_window_stack,
TimelineItemActionSourceSendTextApp,
false /* standalone_reply */);
data->event_service_info = (EventServiceInfo) {
.type = PEBBLE_SYS_NOTIFICATION_EVENT,
.handler = prv_action_handle_response,
.context = data,
};
event_service_client_subscribe(&data->event_service_info);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Contact list functions
static void prv_clear_contact_list(SendTextAppData *data) {
while (data->contact_list_head) {
ContactNode *old_head = data->contact_list_head;
data->contact_list_head = (ContactNode *)list_pop_head((ListNode *)old_head);
// Only need to free display_number since number shares the same buffer
app_free(old_head->display_number);
app_free(old_head->name);
app_free(old_head);
}
}
static void prv_add_contact_to_list(ContactId id, const char *name, const char *number, bool is_fav,
void *callback_context) {
SendTextAppData *data = callback_context;
ContactNode *new_node = app_zalloc_check(sizeof(ContactNode));
list_init((ListNode *)new_node);
new_node->id = id;
const size_t name_size = strlen(name) + 1;
new_node->name = app_zalloc_check(name_size);
strcpy(new_node->name, name);
const char *fav_str = (is_fav ? "" : "");
const size_t display_number_size = strlen(fav_str) + strlen(number) + 1;
new_node->display_number = app_zalloc_check(display_number_size);
strcpy(new_node->display_number, fav_str);
strcat(new_node->display_number, number);
// Store the number string (the part after "fav_str") to forward to the phone
new_node->number = (new_node->display_number + strlen(fav_str));
list_append((ListNode *)data->contact_list_head, (ListNode *)new_node);
if (!data->contact_list_head) {
data->contact_list_head = new_node;
}
}
static void prv_read_contacts_from_prefs(SendTextAppData *data) {
SerializedSendTextPrefs *prefs = watch_app_prefs_get_send_text();
if (prefs) {
int num_contacts = 0;
for (int i = 0; i < prefs->num_contacts; i++) {
SerializedSendTextContact *pref = &prefs->contacts[i];
Contact *contact = contacts_get_contact_by_uuid(&pref->contact_uuid);
if (!contact) {
continue;
}
for (int j = 0; j < contact->addr_list.num_addresses; j++) {
if (uuid_equal(&contact->addr_list.addresses[j].id, &pref->address_uuid)) {
const char *name = attribute_get_string(&contact->attr_list, AttributeIdTitle,
(char *)i18n_get("Unknown", data));
const char *number = attribute_get_string(&contact->addr_list.addresses[j].attr_list,
AttributeIdAddress, "");
prv_add_contact_to_list(num_contacts++, name, number, pref->is_fav, data);
}
}
contacts_free_contact(contact);
}
}
task_free(prefs);
}
static void prv_update_contact_list(SendTextAppData *data) {
prv_clear_contact_list(data);
prv_read_contacts_from_prefs(data);
}
static bool prv_has_contacts(SendTextAppData *data) {
return (list_count((ListNode *)data->contact_list_head) > 0);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! Menu Layer Callbacks
static uint16_t prv_contact_list_get_num_rows_callback(MenuLayer *menu_layer,
uint16_t section_index,
void *callback_context) {
SendTextAppData *data = callback_context;
return (uint16_t)list_count((ListNode *)data->contact_list_head);
}
static int16_t prv_contact_list_get_header_height_callback(MenuLayer *menu_layer,
uint16_t section_index,
void *callback_context) {
return MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT;
}
static int16_t prv_contact_list_get_cell_height_callback(MenuLayer *menu_layer,
MenuIndex *cell_index,
void *callback_context) {
return PBL_IF_RECT_ELSE(menu_cell_basic_cell_height(),
(menu_layer_is_index_selected(menu_layer, cell_index) ?
MENU_CELL_ROUND_FOCUSED_SHORT_CELL_HEIGHT :
MENU_CELL_ROUND_UNFOCUSED_TALL_CELL_HEIGHT));
}
static void prv_contact_list_draw_header_callback(GContext *ctx, const Layer *cell_layer,
uint16_t section_index, void *callback_context) {
SendTextAppData *data = callback_context;
const MenuIndex menu_index = menu_layer_get_selected_index(&data->menu_layer);
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_14);
GRect box = cell_layer->bounds;
box.origin.y -= 2;
graphics_context_set_text_color(ctx, GColorDarkGray);
graphics_draw_text(ctx, i18n_get("Select Contact", data), font, box, GTextOverflowModeFill,
GTextAlignmentCenter, NULL);
}
static void prv_contact_list_draw_row_callback(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *callback_context) {
SendTextAppData *data = (SendTextAppData *)callback_context;
ContactNode *node = (ContactNode *)list_get_at((ListNode *)data->contact_list_head,
cell_index->row);
if (!node) {
return;
}
menu_cell_basic_draw(ctx, cell_layer, node->name, node->display_number, NULL);
}
static void prv_contact_list_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index,
void *callback_context) {
SendTextAppData *data = (SendTextAppData *)callback_context;
ContactNode *node = (ContactNode *)list_get_at((ListNode *)data->contact_list_head,
cell_index->row);
if (!node) {
return;
}
prv_open_action_menu(data, node->number);
}
///////////////////////////////////////////////////////////////////////////////////////////////////
//! App boilerplate
static void prv_init(void) {
SendTextAppData *data = app_zalloc_check(sizeof(SendTextAppData));
app_state_set_user_data(data);
Window *window = &data->window;
window_init(window, WINDOW_NAME("Send Text"));
window_set_user_data(window, data);
Layer *window_root_layer = &window->layer;
prv_update_contact_list(data);
if (prv_has_contacts(data)) {
const GRect menu_layer_frame =
grect_inset(window_root_layer->bounds,
GEdgeInsets(STATUS_BAR_LAYER_HEIGHT, 0,
PBL_IF_ROUND_ELSE(STATUS_BAR_LAYER_HEIGHT, 0), 0));
menu_layer_init(&data->menu_layer, &menu_layer_frame);
menu_layer_set_callbacks(&data->menu_layer, data, &(MenuLayerCallbacks) {
.get_num_rows = prv_contact_list_get_num_rows_callback,
.get_cell_height = prv_contact_list_get_cell_height_callback,
// On round we show the "Select Contact" text in a menu cell header, but on rect we show it
// in the status bar (see below)
#if PBL_ROUND
.draw_header = prv_contact_list_draw_header_callback,
.get_header_height = prv_contact_list_get_header_height_callback,
#endif
.draw_row = prv_contact_list_draw_row_callback,
.select_click = prv_contact_list_select_callback,
});
menu_layer_set_highlight_colors(&data->menu_layer, SEND_TEXT_APP_HIGHLIGHT_COLOR, GColorWhite);
menu_layer_set_click_config_onto_window(&data->menu_layer, &data->window);
layer_add_child(&data->window.layer, menu_layer_get_layer(&data->menu_layer));
StatusBarLayer *status_layer = &data->status_layer;
status_bar_layer_init(status_layer);
status_bar_layer_set_colors(status_layer, GColorClear, GColorBlack);
// On rect we show the "Select Contact" text in the status bar, but on round the status bar
// shows the clock time and we use a menu cell header to display "Select Contact" (see above)
#if PBL_RECT
status_bar_layer_set_title(status_layer, i18n_get("Select Contact", data), false /* revert */,
false /* animate */);
status_bar_layer_set_separator_mode(status_layer, StatusBarLayerSeparatorModeDotted);
#endif
layer_add_child(window_root_layer, status_bar_layer_get_layer(&data->status_layer));
} else {
PeekLayer *peek_layer = &data->no_contacts_layer;
peek_layer_init(peek_layer, &data->window.layer.bounds);
const GFont title_font = system_theme_get_font_for_default_size(TextStyleFont_Title);
peek_layer_set_title_font(peek_layer, title_font);
TimelineResourceInfo timeline_res = {
.res_id = TIMELINE_RESOURCE_GENERIC_WARNING,
};
peek_layer_set_icon(peek_layer, &timeline_res);
peek_layer_set_title(peek_layer, i18n_get("Add contacts in\nmobile app", data));
peek_layer_set_background_color(peek_layer, GColorLightGray);
peek_layer_play(peek_layer);
layer_add_child(window_root_layer, &peek_layer->layer);
}
const bool animated = true;
app_window_stack_push(&data->window, animated);
}
static void prv_deinit(void) {
SendTextAppData *data = app_state_get_user_data();
event_service_client_unsubscribe(&data->event_service_info);
status_bar_layer_deinit(&data->status_layer);
peek_layer_deinit(&data->no_contacts_layer);
menu_layer_deinit(&data->menu_layer);
i18n_free_all(data);
prv_clear_contact_list(data);
app_free(data);
}
static void prv_main(void) {
prv_init();
app_event_loop();
prv_deinit();
}
const PebbleProcessMd *send_text_app_get_info(void) {
static const PebbleProcessMdSystem s_send_text_app_info = {
.common = {
.main_func = prv_main,
.uuid = UUID_SEND_TEXT_DATA_SOURCE,
},
.name = i18n_noop("Send Text"),
.icon_resource_id = RESOURCE_ID_SEND_TEXT_APP_GLANCE,
};
// If the phone doesn't support this app, we will act as if it's not installed by returning NULL
const bool app_supported = send_text_service_is_send_text_supported();
return app_supported ? (const PebbleProcessMd *)&s_send_text_app_info : NULL;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* send_text_app_get_info();

View file

@ -0,0 +1,31 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "util/attributes.h"
#include "util/uuid.h"
typedef struct PACKED {
Uuid contact_uuid;
Uuid address_uuid;
bool is_fav;
} SerializedSendTextContact;
typedef struct PACKED {
uint8_t num_contacts;
SerializedSendTextContact contacts[];
} SerializedSendTextPrefs;

View file

@ -0,0 +1,154 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings.h"
#include "settings_menu.h"
#include "settings_window.h"
#include "applib/app.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/ui.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "system/passert.h"
#define SETTINGS_CATEGORY_MENU_CELL_UNFOCUSED_ROUND_VERTICAL_PADDING 14
typedef struct {
Window window;
MenuLayer menu_layer;
} SettingsAppData;
static uint16_t prv_get_num_rows_callback(MenuLayer *menu_layer,
uint16_t section_index, void *context) {
return SettingsMenuItem_Count;
}
static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer,
MenuIndex *cell_index, void *context) {
SettingsAppData *data = context;
PBL_ASSERTN(cell_index->row < SettingsMenuItem_Count);
const char *category_title = settings_menu_get_submodule_info(cell_index->row)->name;
const char *title = i18n_get(category_title, data);
menu_cell_basic_draw(ctx, cell_layer, title, NULL, NULL);
}
static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *context) {
settings_menu_push(cell_index->row);
}
static int16_t prv_get_cell_height_callback(MenuLayer *menu_layer,
MenuIndex *cell_index, void *context) {
PBL_ASSERTN(cell_index->row < SettingsMenuItem_Count);
#if PBL_RECT
const int16_t category_title_height = 37;
return category_title_height;
#else
const int16_t focused_cell_height = MENU_CELL_ROUND_FOCUSED_SHORT_CELL_HEIGHT;
const int16_t unfocused_cell_height =
((DISP_ROWS - focused_cell_height) / 2) -
SETTINGS_CATEGORY_MENU_CELL_UNFOCUSED_ROUND_VERTICAL_PADDING;
return menu_layer_is_index_selected(menu_layer, cell_index) ? focused_cell_height :
unfocused_cell_height;
#endif
}
static int16_t prv_get_separator_height_callback(MenuLayer *menu_layer,
MenuIndex *cell_index,
void *context) {
return 0;
}
static void prv_window_load(Window *window) {
SettingsAppData *data = window_get_user_data(window);
// Create the menu
GRect bounds = data->window.layer.bounds;
#if PBL_ROUND
bounds = grect_inset_internal(bounds, 0,
SETTINGS_CATEGORY_MENU_CELL_UNFOCUSED_ROUND_VERTICAL_PADDING);
#endif
MenuLayer *menu_layer = &data->menu_layer;
menu_layer_init(menu_layer, &bounds);
menu_layer_set_callbacks(menu_layer, data, &(MenuLayerCallbacks) {
.get_num_rows = prv_get_num_rows_callback,
.get_cell_height = prv_get_cell_height_callback,
.draw_row = prv_draw_row_callback,
.select_click = prv_select_callback,
.get_separator_height = prv_get_separator_height_callback
});
menu_layer_set_normal_colors(menu_layer,
PBL_IF_COLOR_ELSE(GColorBlack, GColorWhite),
PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack));
menu_layer_set_highlight_colors(menu_layer,
PBL_IF_COLOR_ELSE(SETTINGS_MENU_HIGHLIGHT_COLOR, GColorBlack),
GColorWhite);
menu_layer_set_click_config_onto_window(menu_layer, &data->window);
layer_add_child(&data->window.layer, menu_layer_get_layer(menu_layer));
}
static void prv_window_unload(Window *window) {
SettingsAppData *data = window_get_user_data(window);
menu_layer_deinit(&data->menu_layer);
app_free(data);
}
static void handle_init(void) {
SettingsAppData *data = app_zalloc_check(sizeof(SettingsAppData));
Window *window = &data->window;
window_init(window, WINDOW_NAME("Settings"));
window_set_user_data(window, data);
window_set_window_handlers(window, &(WindowHandlers){
.load = prv_window_load,
.unload = prv_window_unload,
});
window_set_background_color(window, GColorBlack);
app_window_stack_push(window, true);
}
static void handle_deinit(void) {
// Window unload deinits everything
}
static void s_main(void) {
handle_init();
app_event_loop();
handle_deinit();
}
const PebbleProcessMd *settings_get_app_info() {
static const PebbleProcessMdSystem s_settings_app = {
.common = {
.main_func = s_main,
// UUID: 07e0d9cb-8957-4bf7-9d42-35bf47caadfe
.uuid = {0x07, 0xe0, 0xd9, 0xcb, 0x89, 0x57, 0x4b, 0xf7,
0x9d, 0x42, 0x35, 0xbf, 0x47, 0xca, 0xad, 0xfe},
},
.name = i18n_noop("Settings"),
#if CAPABILITY_HAS_APP_GLANCES
.icon_resource_id = RESOURCE_ID_SETTINGS_TINY,
#elif PLATFORM_TINTIN
.icon_resource_id = RESOURCE_ID_MENU_LAYER_SETTINGS_APP_ICON,
#endif
};
return (const PebbleProcessMd*) &s_settings_app;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "process_management/pebble_process_md.h"
const PebbleProcessMd* settings_get_app_info();

View file

@ -0,0 +1,276 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings_activity_tracker.h"
#include "settings_menu.h"
#include "settings_window.h"
#include "applib/app.h"
#include "applib/app_timer.h"
#include "applib/ui/kino/kino_reel.h"
#include "applib/ui/option_menu_window.h"
#include "applib/ui/ui.h"
#include "applib/ui/window.h"
#include "applib/ui/window_stack.h"
#include "kernel/event_loop.h"
#include "kernel/pbl_malloc.h"
#include "popups/switch_worker_ui.h"
#include "process_management/app_menu_data_source.h"
#include "process_management/worker_manager.h"
#include "process_state/app_state/app_state.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "shell/normal/watchface.h"
#include "system/passert.h"
#include <string.h>
typedef struct SettingsActivityTrackerData {
OptionMenu option_menu;
MenuLayer menu_layer;
AppMenuDataSource *data_source;
TextLayer *text_layer;
EventServiceInfo worker_launch_info;
} SettingsActivityTrackerData;
////////////////////
// AppMenuDataSource callbacks
static bool prv_app_filter_callback(struct AppMenuDataSource *const source,
AppInstallEntry *entry) {
if (!app_install_entry_is_hidden(entry) &&
app_install_entry_has_worker(entry)) {
return true;
}
return false;
}
static int16_t prv_get_chosen_row_index_for_id(SettingsActivityTrackerData *data,
AppInstallId worker_id) {
if (worker_id == INSTALL_ID_INVALID) {
return 0;
}
const uint16_t current_worker_app_index =
app_menu_data_source_get_index_of_app_with_install_id(data->data_source, worker_id);
if (current_worker_app_index == MENU_INDEX_NOT_FOUND) {
return 0;
} else {
return current_worker_app_index + 1;
}
}
// Gets the current chosen row index; i.e., the row which was most recently chosen by the user.
static int16_t prv_get_chosen_row_index(SettingsActivityTrackerData *data) {
const AppInstallId worker_id = worker_manager_get_current_worker_id();
return prv_get_chosen_row_index_for_id(data, worker_id);
}
static int prv_num_rows(SettingsActivityTrackerData *data) {
if (data->data_source) {
return app_menu_data_source_get_count(data->data_source);
} else {
return 0;
}
}
static void prv_reload_menu_data(void *context) {
SettingsActivityTrackerData *data = context;
const uint16_t count = prv_num_rows(data);
const bool use_icons = (count != 0);
option_menu_set_icons_enabled(&data->option_menu, use_icons /* icons_enabled */);
option_menu_set_choice(&data->option_menu, prv_get_chosen_row_index(data));
option_menu_reload_data(&data->option_menu);
}
// Settings Menu callbacks
///////////////////////////
static void prv_select_cb(OptionMenu *option_menu, int row, void *context) {
SettingsActivityTrackerData *data = context;
if (app_menu_data_source_get_count(data->data_source) == 0) {
return;
}
if (row == 0) {
// Killing current worker
process_manager_put_kill_process_event(PebbleTask_Worker, true /* graceful */);
worker_manager_set_default_install_id(INSTALL_ID_INVALID);
} else {
const uint16_t app_index = row - 1; // offset because of the "None" selection
const AppMenuNode *app_node =
app_menu_data_source_get_node_at_index(data->data_source, app_index);
if (worker_manager_get_task_context()->install_id == INSTALL_ID_INVALID) {
// No worker currently running, launch this one and make it the default
worker_manager_put_launch_worker_event(app_node->install_id);
worker_manager_set_default_install_id(app_node->install_id);
} else if (worker_manager_get_task_context()->install_id != app_node->install_id) {
// Undo the choice change that the OptionMenu does before we call select. We may decline
// the change and therefore we don't want it to visually update yet. prv_worker_launch_handler
// will update the choice if it fires.
option_menu_set_choice(&data->option_menu, prv_get_chosen_row_index(data));
// Switching to a different worker, display confirmation dialog
switch_worker_confirm(app_node->install_id, true /* set as default */,
app_state_get_window_stack());
} else {
// User selected the option they already had, do nothing
}
}
}
static void prv_draw_no_activities_cell_rect(GContext *ctx, const Layer *cell_layer,
const char *no_activities_string) {
const GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
GRect box = cell_layer->bounds;
const GTextOverflowMode overflow = GTextOverflowModeTrailingEllipsis;
const GTextAlignment alignment = GTextAlignmentCenter;
const GSize text_size = graphics_text_layout_get_max_used_size(ctx, no_activities_string, font,
box, overflow, alignment, NULL);
// We want to position the text in the center of the cell veritically,
// we divide the height of the cell by two and subtract half of the text size.
// However, that just puts the TOP of a line vertically aligned.
// So we also have to subtract half of a single line's width.
box.origin.y = (box.size.h - text_size.h - fonts_get_font_height(font)/2) / 2;
graphics_draw_text(ctx, no_activities_string, font, box, overflow, alignment, NULL);
}
static void prv_draw_no_activities_cell_round(GContext *ctx, const Layer *cell_layer,
const char *no_activities_string) {
menu_cell_basic_draw(ctx, cell_layer, no_activities_string, NULL, NULL);
}
static uint16_t prv_get_num_rows_cb(OptionMenu *option_menu, void *context) {
SettingsActivityTrackerData *data = context;
const uint16_t count = prv_num_rows(data);
return count + 1;
}
static void prv_draw_row_cb(OptionMenu *option_menu, GContext *ctx, const Layer *cell_layer,
const GRect *text_frame, uint32_t row, bool selected, void *context) {
SettingsActivityTrackerData *data = context;
if (prv_num_rows(data) == 0) {
// Draw "No background apps" box and exit
const char *no_background_apps_string = i18n_get("No background apps", data);
PBL_IF_RECT_ELSE(prv_draw_no_activities_cell_rect,
prv_draw_no_activities_cell_round)
(ctx, cell_layer, no_background_apps_string);
return;
}
const char *title = NULL;
if (row == 0) {
title = i18n_get("None", data);
} else {
AppMenuNode *node = app_menu_data_source_get_node_at_index(data->data_source, row - 1);
title = node->name;
}
option_menu_system_draw_row(option_menu, ctx, cell_layer, text_frame, title, false, NULL);
}
static uint16_t prv_row_height_cb(OptionMenu *option_menu, uint16_t row, bool is_selected,
void *context) {
const int16_t cell_height =
option_menu_default_cell_height(option_menu->content_type, is_selected);
#if PBL_RECT
if (prv_num_rows(context) == 0) {
// When we have no background apps, we want a double height row to display the
// 'No background apps' line, so that translations can fit and we stop wasting so much screen
// space.
return 2 * cell_height;
}
#endif
return cell_height;
}
static void prv_worker_launch_handler(PebbleEvent *event, void *context) {
// Our worker changed while we were visible, update the selected choice
SettingsActivityTrackerData *data = context;
const AppInstallId worker_id = event->launch_app.id;
const int16_t chosen_row = prv_get_chosen_row_index_for_id(data, worker_id);
option_menu_set_choice(&data->option_menu, chosen_row);
}
static void prv_unload_cb(OptionMenu *option_menu, void *context) {
SettingsActivityTrackerData *data = context;
event_service_client_unsubscribe(&data->worker_launch_info);
app_menu_data_source_deinit(data->data_source);
app_free(data->data_source);
data->data_source = NULL;
option_menu_deinit(&data->option_menu);
i18n_free_all(data);
app_free(data);
}
static Window *prv_init(void) {
SettingsActivityTrackerData *data = app_zalloc_check(sizeof(SettingsActivityTrackerData));
const OptionMenuCallbacks option_menu_callbacks = {
.unload = prv_unload_cb,
.draw_row = prv_draw_row_cb,
.select = prv_select_cb,
.get_num_rows = prv_get_num_rows_cb,
.get_cell_height = prv_row_height_cb,
};
data->data_source = app_zalloc_check(sizeof(AppMenuDataSource));
app_menu_data_source_init(data->data_source, &(AppMenuDataSourceCallbacks) {
.changed = prv_reload_menu_data,
.filter = prv_app_filter_callback,
}, data);
option_menu_init(&data->option_menu);
// Not using option_menu_configure because prv_reload_menu_data already sets
// icons_enabled and chosen row index
option_menu_set_status_colors(&data->option_menu, GColorWhite, GColorBlack);
option_menu_set_highlight_colors(&data->option_menu, SETTINGS_MENU_HIGHLIGHT_COLOR, GColorWhite);
option_menu_set_title(&data->option_menu, i18n_get("Background App", data));
option_menu_set_content_type(&data->option_menu, OptionMenuContentType_SingleLine);
option_menu_set_callbacks(&data->option_menu, &option_menu_callbacks, data);
prv_reload_menu_data(data);
data->worker_launch_info = (EventServiceInfo) {
.type = PEBBLE_WORKER_LAUNCH_EVENT,
.handler = prv_worker_launch_handler,
.context = data
};
event_service_client_subscribe(&data->worker_launch_info);
return &data->option_menu.window;
}
const SettingsModuleMetadata *settings_activity_tracker_get_info(void) {
static const SettingsModuleMetadata s_module_info = {
.name = i18n_noop("Background App"),
.init = prv_init,
};
return &s_module_info;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "settings_menu.h"
const SettingsModuleMetadata *settings_activity_tracker_get_info(void);

View file

@ -0,0 +1,677 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#define FILE_LOG_COLOR LOG_COLOR_BLUE
#include "settings_bluetooth.h"
#include "settings_menu.h"
#include "settings_remote.h"
#include "settings_window.h"
#include "applib/app.h"
#include "applib/app_focus_service.h"
#include "applib/event_service_client.h"
#include "applib/fonts/fonts.h"
#include "applib/graphics/graphics.h"
#include "applib/graphics/gtypes.h"
#include "applib/ui/ui.h"
#include "comm/bt_lock.h"
#include "comm/ble/gap_le_connection.h"
#include "comm/ble/gap_le_device_name.h"
#include "drivers/rtc.h"
#include "kernel/pbl_malloc.h"
#include "kernel/ui/system_icons.h"
#include "resource/resource_ids.auto.h"
#include "services/common/analytics/analytics.h"
#include "services/common/bluetooth/bluetooth_persistent_storage.h"
#include "services/common/bluetooth/local_id.h"
#include "services/common/bluetooth/pairability.h"
#include "services/common/i18n/i18n.h"
#include "services/common/system_task.h"
#include "services/normal/bluetooth/ble_hrm.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/string.h"
#include <bluetooth/bluetooth_types.h>
#include <bluetooth/classic_connect.h>
#include <bluetooth/reconnect.h>
#include <bluetooth/sm_types.h>
#include <btutil/bt_device.h>
#include <stdio.h>
#include <string.h>
#define HEADER_BUFFER_SIZE 22
#define SHARING_HEART_RATE_EXTRA_HEIGHT_PX (18)
typedef enum SettingsBluetooth {
SettingsBluetoothAirplaneMode,
SettingsBluetoothTotal,
} SettingsBluetooth;
enum {
BluetoothIconIdx,
BluetoothAltIconIdx,
AirplaneIconIdx,
NumIcons,
};
static const uint32_t ICON_RESOURCE_ID[NumIcons] = {
RESOURCE_ID_SETTINGS_ICON_BLUETOOTH,
RESOURCE_ID_SETTINGS_ICON_BLUETOOTH_ALT,
RESOURCE_ID_SETTINGS_ICON_AIRPLANE,
};
typedef enum {
ToggleStateIdle,
ToggleStateEnablingBluetooth,
ToggleStateDisablingBluetooth,
} ToggleState;
typedef struct SettingsBluetoothData {
SettingsCallbacks callbacks;
GBitmap icon_heap_bitmap[NumIcons];
ListNode* remote_list_head;
char header_buffer[HEADER_BUFFER_SIZE];
ToggleState toggle_state;
EventServiceInfo bt_airplane_event_info;
EventServiceInfo bt_connection_event_info;
EventServiceInfo bt_pairing_event_info;
EventServiceInfo ble_device_name_updated_event_info;
#if CAPABILITY_HAS_BUILTIN_HRM
EventServiceInfo ble_hrm_sharing_event_info;
#endif
} SettingsBluetoothData;
// BT stack interaction stuff
///////////////////////////
static void settings_bluetooth_reconnect_once(void) {
// After the user toggles BT back on, immediately attempt to reconnect once:
if (bt_ctl_is_airplane_mode_on() == false) {
bt_driver_reconnect_try_now(true /*ignore_paused*/);
}
}
static void settings_bluetooth_toggle_airplane_mode(SettingsBluetoothData* data) {
const bool airplane_mode = bt_ctl_is_airplane_mode_on();
bt_ctl_set_airplane_mode_async(!airplane_mode);
data->toggle_state =
airplane_mode ? ToggleStateEnablingBluetooth : ToggleStateDisablingBluetooth;
settings_menu_mark_dirty(SettingsMenuItemBluetooth);
}
bool is_remote_connected(StoredRemote* remote) {
switch (remote->type) {
case StoredRemoteTypeBTClassic:
return remote->classic.connected;
case StoredRemoteTypeBLE:
return (remote->ble.connection != NULL);
case StoredRemoteTypeBTDual:
return remote->dual.classic.connected || (remote->dual.ble.connection != NULL);
default:
WTF;
}
return false;
}
static int remote_comparator(StoredRemote* remote, StoredRemote* other) {
if (is_remote_connected(remote) != is_remote_connected(other)) {
return is_remote_connected(remote) ? -1 : 1;
} else {
return strncmp(remote->name, other->name, sizeof(remote->name));
}
}
static void add_remote(SettingsBluetoothData* data, StoredRemote* remote) {
const bool ascending = false;
data->remote_list_head = list_sorted_add(data->remote_list_head, &remote->list_node,
(Comparator) remote_comparator, ascending);
}
static StoredRemote* stored_remote_create(void) {
StoredRemote* remote = task_malloc_check(sizeof(*remote));
*remote = (StoredRemote){};
return remote;
}
static void prv_copy_device_name_with_fallback(StoredRemote *remote, const char *name) {
if (!name || strlen(name) == 0) {
i18n_get_with_buffer("<Untitled>", remote->name, sizeof(remote->name));
} else {
strncpy(remote->name, name, sizeof(remote->name));
}
}
static void prv_add_bt_classic_remote(BTDeviceAddress *addr, SM128BitKey *link_key,
const char *name, uint8_t *platform_bits, void *context) {
SettingsBluetoothData *data = (SettingsBluetoothData*) context;
if (!data) {
return;
}
// Determine the address of our active remote, if we have one.
BTDeviceAddress active_addr = {};
const bool is_connected = bt_driver_classic_copy_connected_address(&active_addr);
// Create the new remote
StoredRemote *remote = stored_remote_create();
remote->classic.bd_addr = *addr;
prv_copy_device_name_with_fallback(remote, name);
if (is_connected && (0 == memcmp(addr, &active_addr, sizeof(*addr)))) {
remote->classic.connected = true;
} else {
remote->classic.connected = false;
}
add_remote(data, remote);
}
static void prv_add_bt_classic_remotes(SettingsBluetoothData *data) {
bt_persistent_storage_for_each_bt_classic_pairing(prv_add_bt_classic_remote, data);
}
static bool dual_remote_filter(ListNode *node, void *data) {
StoredRemote *classic_remote = (StoredRemote *) node;
BTDeviceInternal *device = (BTDeviceInternal *) data;
BTDeviceInternal le_device_with_classic_address = (const BTDeviceInternal) {
.address = classic_remote->classic.bd_addr,
.is_random_address = false,
};
return bt_device_equal(&le_device_with_classic_address.opaque, &device->opaque);
}
static void prv_add_and_merge_ble_remote(BTDeviceInternal *device, SMIdentityResolvingKey *irk,
const char *name, BTBondingID *id, void *context) {
SettingsBluetoothData *data = (SettingsBluetoothData*) context;
if (!data) {
return;
}
StoredRemote* remote = (StoredRemote*) list_find_next(data->remote_list_head,
dual_remote_filter, true, device);
if (remote) {
// The remote is also a ble device, promote to a dual remote
const bool classic_connected = remote->classic.connected;
remote->type = StoredRemoteTypeBTDual;
remote->dual.classic.connected = classic_connected;
// Note: We update remote->dual.ble.connected outside this cb
remote->dual.ble.bonding = *id;
} else {
// Remote for which we only have a BLE key, add it in the menu as well, so it is accessible
// and can be removed by the user:
StoredRemote* remote = stored_remote_create();
remote->type = StoredRemoteTypeBLE;
// Note: We update remote->ble.connection outside this cb
remote->ble.bonding = *id;
prv_copy_device_name_with_fallback(remote, name);
add_remote(data, remote);
}
}
//! This must be called after updating classic remotes for remote consolidation
static void prv_add_and_merge_ble_remotes(SettingsBluetoothData *data) {
bt_persistent_storage_for_each_ble_pairing(prv_add_and_merge_ble_remote, data);
StoredRemote *remote = (StoredRemote *)data->remote_list_head;
while (remote) {
StoredRemoteBLE *ble_rem = NULL;
if (remote->type == StoredRemoteTypeBLE) {
ble_rem = &remote->ble;
} else if (remote->type == StoredRemoteTypeBTDual) {
ble_rem = &remote->dual.ble;
}
if (ble_rem) {
SMIdentityResolvingKey irk;
BTDeviceInternal device;
if (bt_persistent_storage_get_ble_pairing_by_id(ble_rem->bonding, &irk, &device, NULL)) {
bt_lock();
GAPLEConnection *connection = gap_le_connection_find_by_irk(&irk);
if (!connection) {
connection = gap_le_connection_by_device(&device);
}
ble_rem->connection = connection;
#if CAPABILITY_HAS_BUILTIN_HRM
ble_rem->is_sharing_heart_rate = ble_hrm_is_sharing_to_connection(connection);
#endif
bt_unlock();
}
}
remote = (StoredRemote *)remote->list_node.next;
}
}
static void prv_clear_remote_list(SettingsBluetoothData* data) {
while (data->remote_list_head) {
StoredRemote* remote = (StoredRemote*) data->remote_list_head;
data->remote_list_head = list_pop_head(&remote->list_node);
task_free(remote);
}
}
static void prv_reload_remote_list(SettingsBluetoothData* data) {
prv_clear_remote_list(data);
prv_add_bt_classic_remotes(data);
prv_add_and_merge_ble_remotes(data);
}
static void settings_bluetooth_update_remotes_private(SettingsBluetoothData* data) {
prv_reload_remote_list(data);
if (!data->remote_list_head) {
strncpy(data->header_buffer, i18n_get("Pairing Instructions", data), HEADER_BUFFER_SIZE);
} else {
const unsigned int num_remotes = list_count(data->remote_list_head);
sniprintf(data->header_buffer, HEADER_BUFFER_SIZE,
(num_remotes != 1) ? i18n_get("%u Paired Phones", data) :
i18n_get("%u Paired Phone", data),
num_remotes);
}
}
void settings_bluetooth_update_remotes(SettingsBluetoothData *data) {
settings_bluetooth_update_remotes_private(data);
settings_menu_reload_data(SettingsMenuItemBluetooth);
}
//////////
static void prv_settings_bluetooth_event_handler(PebbleEvent *event, void *context) {
SettingsBluetoothData* settings_data = (SettingsBluetoothData *) context;
PBL_LOG_COLOR(LOG_LEVEL_DEBUG, LOG_COLOR_BLUE, "BT EVENT");
switch (event->type) {
case PEBBLE_BT_CONNECTION_EVENT:
// If BT Settings is open, update BLE device name upon connecting device:
if (event->bluetooth.connection.is_ble &&
event->bluetooth.connection.state == PebbleBluetoothConnectionEventStateConnected) {
// https://pebbletechnology.atlassian.net/browse/PBL-22176
// iOS seems to respond with 0x0E (Unlikely Error) when performing this request while
// the encryption set up is going on. For non-bonded devices it will work fine though.
gap_le_device_name_request(&event->bluetooth.connection.device);
}
// fall-through!
case PEBBLE_BT_PAIRING_EVENT:
#if CAPABILITY_HAS_BUILTIN_HRM
case PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT:
#endif
case PEBBLE_BLE_DEVICE_NAME_UPDATED_EVENT: {
settings_bluetooth_update_remotes_private(settings_data);
settings_menu_mark_dirty(SettingsMenuItemBluetooth);
break;
}
case PEBBLE_BT_STATE_EVENT: {
settings_bluetooth_reconnect_once();
settings_data->toggle_state = ToggleStateIdle;
settings_menu_mark_dirty(SettingsMenuItemBluetooth);
break;
}
default:
break;
}
}
// UI Stuff
/////////////////////////////
// Menu Layer Callbacks
/////////////////////////////
//-- Address
// ...
//| Airplane Mode: Off
//-- Paired Devices
//| Device Name
// Connected
//| Device Name
//
static void prv_draw_stored_remote_item_rect(GContext *ctx, const Layer *cell_layer,
const char *remote_name, const char *connected_string,
const char *le_string,
const char *is_sharing_heart_rate_string) {
const GFont font = ((le_string || is_sharing_heart_rate_string) ?
fonts_get_system_font(FONT_KEY_GOTHIC_18) : NULL);
if (le_string) {
GRect box = cell_layer->bounds;
box.size.w -= 5;
box.origin.y += 20;
box.size.h = 24;
graphics_draw_text(ctx, le_string, font, box, GTextOverflowModeFill, GTextAlignmentRight, NULL);
}
if (is_sharing_heart_rate_string) {
const int horizontal_margin = menu_cell_basic_horizontal_inset();
GRect box = grect_inset(cell_layer->bounds, GEdgeInsets(0, horizontal_margin));
box.origin.y += 38;
box.size.h = 24;
graphics_draw_text(ctx, is_sharing_heart_rate_string, font, box,
GTextOverflowModeFill, GTextAlignmentLeft, NULL);
// Gross hack to avoid centering the title / subtitle labels in the entire cell:
((Layer *)cell_layer)->bounds.size.h -= SHARING_HEART_RATE_EXTRA_HEIGHT_PX;
}
menu_cell_basic_draw(ctx, cell_layer, remote_name, connected_string, NULL);
if (is_sharing_heart_rate_string) {
// Restore original height:
((Layer *)cell_layer)->bounds.size.h += SHARING_HEART_RATE_EXTRA_HEIGHT_PX;
}
}
bool settings_bluetooth_is_sharing_heart_rate_for_stored_remote(StoredRemote* remote) {
#if CAPABILITY_HAS_BUILTIN_HRM
switch (remote->type) {
case StoredRemoteTypeBLE: return remote->ble.is_sharing_heart_rate;
case StoredRemoteTypeBTDual: return remote->dual.ble.is_sharing_heart_rate;
default:
return false;
}
#else
return false;
#endif // CAPABILITY_HAS_BUILTIN_HRM
}
#if PBL_ROUND
static void prv_draw_stored_remote_item_round(GContext *ctx, const Layer *cell_layer,
const char *remote_name, const char *connected_string,
const char *le_string,
const char *is_sharing_heart_rate_string) {
# if CAPABILITY_HAS_BUILTIN_HRM
_Static_assert(false, "FIXME: Implement round drawing code to show heart rate sharing status!");
# endif // CAPABILITY_HAS_BUILTIN_HRM
menu_cell_basic_draw(ctx, cell_layer, remote_name, connected_string, NULL);
}
#endif // PBL_ROUND
static void draw_stored_remote_item(GContext *ctx, const Layer *cell_layer,
uint16_t device_index, SettingsBluetoothData *data) {
const uint32_t num_remotes = list_count(data->remote_list_head);
PBL_ASSERT(device_index < num_remotes, "Got index %" PRId16 " only have %" PRId32,
device_index, num_remotes);
StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, device_index);
bool connected = is_remote_connected(remote);
const char *le_string = NULL;
if (remote->type == StoredRemoteTypeBTDual
&& remote->dual.classic.connected != (remote->dual.ble.connection != NULL)) {
le_string = remote->dual.classic.connected
? i18n_get("No LE", data) : i18n_get("LE Only", data);
}
const char *connected_string = connected ? i18n_get("Connected", data) :
PBL_IF_RECT_ELSE("", NULL);
// Add ellipsis if the name might have been cut off by the mobile
const char ellipsis[] = UTF8_ELLIPSIS_STRING;
const size_t max_name_size = BT_DEVICE_NAME_BUFFER_SIZE - 2;
const size_t name_size = strnlen(remote->name, BT_DEVICE_NAME_BUFFER_SIZE);
char *remote_name = task_zalloc_check(max_name_size + sizeof(ellipsis));
strncpy(remote_name, remote->name, name_size);
if (name_size > max_name_size) {
const size_t ellipsis_start_offset = utf8_get_size_truncate(remote_name, name_size);
strncpy(&remote_name[ellipsis_start_offset], ellipsis, sizeof(ellipsis));
}
const char *is_sharing_heart_rate =
(settings_bluetooth_is_sharing_heart_rate_for_stored_remote(remote) ?
i18n_get("Sharing Heart Rate ❤", data) : NULL);
PBL_IF_RECT_ELSE(prv_draw_stored_remote_item_rect,
prv_draw_stored_remote_item_round)(ctx, cell_layer, remote_name,
connected_string, le_string,
is_sharing_heart_rate);
task_free(remote_name);
}
static uint16_t prv_num_rows_cb(SettingsCallbacks *context) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
return list_count(data->remote_list_head) + 1;
}
static int16_t prv_row_height_cb(SettingsCallbacks *context, uint16_t row, bool is_selected) {
#if PBL_RECT
# if CAPABILITY_HAS_BUILTIN_HRM
int heart_rate_sharing_text_height = 0;
if (row > 0) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
const uint16_t device_index = row - 1;
StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, device_index);
if (settings_bluetooth_is_sharing_heart_rate_for_stored_remote(remote)) {
heart_rate_sharing_text_height = SHARING_HEART_RATE_EXTRA_HEIGHT_PX;
}
}
# else
const int heart_rate_sharing_text_height = 0;
# endif // CAPABILITY_HAS_BUILTIN_HRM
return menu_cell_basic_cell_height() + heart_rate_sharing_text_height;
#elif PBL_ROUND
return (is_selected ? MENU_CELL_ROUND_FOCUSED_TALL_CELL_HEIGHT :
MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT);
#else
#endif
}
static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx,
const Layer *cell_layer, uint16_t row, bool selected) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
if (row == 0) {
char device_name_buffer[BT_DEVICE_NAME_BUFFER_SIZE];
const char *subtitle = NULL;
const char *title = i18n_get("Connection", data);
GBitmap *icon = NULL;
if (data->toggle_state == ToggleStateIdle) {
if (bt_ctl_is_airplane_mode_on()) {
subtitle = i18n_get("Airplane Mode", data);
icon = &data->icon_heap_bitmap[AirplaneIconIdx];
} else {
if (selected) {
bt_local_id_copy_device_name(device_name_buffer, false);
subtitle = device_name_buffer;
} else {
subtitle = i18n_get("Now Discoverable", data);
}
icon = &data->icon_heap_bitmap[BluetoothIconIdx];
}
} else {
subtitle = (data->toggle_state == ToggleStateDisablingBluetooth)
? i18n_get("Disabling...", data) : i18n_get("Enabling...", data);
icon = &data->icon_heap_bitmap[BluetoothAltIconIdx];
}
menu_cell_basic_draw(ctx, cell_layer, title, subtitle, icon);
// TODO PBL-23111: Decide how we should show these strings on round displays
#if PBL_RECT
// Hack: the pairing instruction is drawn in the cell callback, but outside of the cell...
if (!data->remote_list_head) {
const GDrawState draw_state = ctx->draw_state;
// Enable drawing outside of the cell:
ctx->draw_state.clip_box = ctx->dest_bitmap.bounds;
graphics_context_set_text_color(ctx, GColorBlack);
GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18);
GRect box = cell_layer->bounds;
box.origin.x = 15;
box.origin.y = menu_cell_basic_cell_height() + (int16_t)9;
box.size.w -= 30;
box.size.h = 83;
if (bt_ctl_is_airplane_mode_on()) {
graphics_draw_text(ctx, i18n_get("Disable Airplane Mode to connect.", data), font,
box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL);
} else {
graphics_draw_text(ctx, i18n_get("Open the Pebble app on your phone to connect.", data),
font, box, GTextOverflowModeTrailingEllipsis,
GTextAlignmentCenter, NULL);
}
ctx->draw_state = draw_state;
}
#endif
} else {
const uint16_t device_index = row - 1;
draw_stored_remote_item(ctx, cell_layer, device_index, data);
}
}
static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
if (row == 0) {
settings_bluetooth_toggle_airplane_mode(data);
return;
}
if (!data->remote_list_head) {
return;
}
prv_reload_remote_list(data);
StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, row - 1);
settings_remote_menu_push(data, remote);
}
static void prv_focus_handler(bool in_focus) {
if (!in_focus) {
return;
}
settings_menu_reload_data(SettingsMenuItemBluetooth);
}
static void prv_expand_cb(SettingsCallbacks *context) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
settings_bluetooth_update_remotes_private(data);
// When entering the BT Settings, update device names of all connected devices:
if (!bt_ctl_is_airplane_mode_on()) {
gap_le_device_name_request_all();
}
data->bt_airplane_event_info = (EventServiceInfo) {
.type = PEBBLE_BT_STATE_EVENT,
.handler = prv_settings_bluetooth_event_handler,
.context = data,
};
data->bt_connection_event_info = (EventServiceInfo) {
.type = PEBBLE_BT_CONNECTION_EVENT,
.handler = prv_settings_bluetooth_event_handler,
.context = data,
};
data->bt_pairing_event_info = (EventServiceInfo) {
.type = PEBBLE_BT_PAIRING_EVENT,
.handler = prv_settings_bluetooth_event_handler,
.context = data,
};
data->ble_device_name_updated_event_info = (EventServiceInfo) {
.type = PEBBLE_BLE_DEVICE_NAME_UPDATED_EVENT,
.handler = prv_settings_bluetooth_event_handler,
.context = data,
};
#if CAPABILITY_HAS_BUILTIN_HRM
data->ble_hrm_sharing_event_info = (EventServiceInfo) {
.type = PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT,
.handler = prv_settings_bluetooth_event_handler,
.context = data,
};
event_service_client_subscribe(&data->ble_hrm_sharing_event_info);
#endif
event_service_client_subscribe(&data->bt_airplane_event_info);
event_service_client_subscribe(&data->bt_connection_event_info);
event_service_client_subscribe(&data->bt_pairing_event_info);
event_service_client_subscribe(&data->ble_device_name_updated_event_info);
bt_pairability_use();
bt_driver_reconnect_pause();
// Reload & redraw after pairing popup
app_focus_service_subscribe_handlers((AppFocusHandlers) { .did_focus = prv_focus_handler });
}
// Turns off services that are part of the bluetooth settings menu such as enabling
// discovery. We don't want to keep these services running longer than necessary because
// they consume a fair amount of power
static void prv_hide_cb(SettingsCallbacks *context) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
bt_pairability_release();
bt_driver_reconnect_resume();
bt_driver_reconnect_reset_interval();
bt_driver_reconnect_try_now(false /*ignore_paused*/);
#if CAPABILITY_HAS_BUILTIN_HRM
event_service_client_unsubscribe(&data->ble_hrm_sharing_event_info);
#endif
event_service_client_unsubscribe(&data->bt_airplane_event_info);
event_service_client_unsubscribe(&data->bt_connection_event_info);
event_service_client_unsubscribe(&data->bt_pairing_event_info);
event_service_client_unsubscribe(&data->ble_device_name_updated_event_info);
app_focus_service_unsubscribe();
}
static void prv_deinit_cb(SettingsCallbacks *context) {
SettingsBluetoothData *data = (SettingsBluetoothData *) context;
i18n_free_all(data);
prv_clear_remote_list(data);
for (unsigned int idx = 0; idx < NumIcons; ++idx) {
gbitmap_deinit(&data->icon_heap_bitmap[idx]);
}
app_free(data);
}
static Window *prv_init(void) {
SettingsBluetoothData *data = app_malloc_check(sizeof(SettingsBluetoothData));
*data = (SettingsBluetoothData){};
for (unsigned int idx = 0; idx < NumIcons; ++idx) {
gbitmap_init_with_resource(&data->icon_heap_bitmap[idx], ICON_RESOURCE_ID[idx]);
}
data->callbacks = (SettingsCallbacks) {
.deinit = prv_deinit_cb,
.draw_row = prv_draw_row_cb,
.select_click = prv_select_click_cb,
.num_rows = prv_num_rows_cb,
.row_height = prv_row_height_cb,
.expand = prv_expand_cb,
.hide = prv_hide_cb,
};
return settings_window_create(SettingsMenuItemBluetooth, &data->callbacks);
}
const SettingsModuleMetadata *settings_bluetooth_get_info(void) {
static const SettingsModuleMetadata s_module_info = {
.name = i18n_noop("Bluetooth"),
.init = prv_init,
};
return &s_module_info;
}
#undef HEADER_BUFFER_SIZE

View file

@ -0,0 +1,71 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include <bluetooth/bluetooth_types.h>
#include "kernel/events.h"
#include "settings_menu.h"
#include "util/list.h"
typedef struct GAPLEConnection GAPLEConnection;
typedef enum StoredRemoteType {
StoredRemoteTypeBTClassic,
StoredRemoteTypeBLE,
StoredRemoteTypeBTDual,
} StoredRemoteType;
typedef struct StoredRemoteClassic {
bool connected;
BTDeviceAddress bd_addr;
} StoredRemoteClassic;
typedef struct StoredRemoteBLE {
BTBondingID bonding;
GAPLEConnection *connection;
#if CAPABILITY_HAS_BUILTIN_HRM
bool is_sharing_heart_rate;
#endif
} StoredRemoteBLE;
typedef struct StoredRemoteDual {
StoredRemoteClassic classic;
StoredRemoteBLE ble;
} StoredRemoteDual;
typedef struct StoredRemote {
ListNode list_node;
char name[BT_DEVICE_NAME_BUFFER_SIZE];
StoredRemoteType type;
union {
StoredRemoteClassic classic;
StoredRemoteBLE ble;
StoredRemoteDual dual;
};
} StoredRemote;
struct SettingsBluetoothData;
void settings_bluetooth_update_remotes(struct SettingsBluetoothData *data);
const SettingsModuleMetadata *settings_bluetooth_get_info(void);
bool settings_bluetooth_is_sharing_heart_rate_for_stored_remote(StoredRemote* remote);
#define BT_FORGET_PAIRING_STR \
i18n_noop("Remember to also forget your Pebble's Bluetooth connection from your phone.")

View file

@ -0,0 +1,193 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "mfg/mfg_info.h"
#include <stdbool.h>
//! Which regulatory marks and/or IDs a given product should display.
typedef struct RegulatoryFlags {
//! Australia Regulatory Compliance Mark
bool has_australia_rcm:1;
//! Canada IC ID
bool has_canada_ic:1;
//! China CMIIT ID
bool has_china_cmiit:1;
//! EU CE Mark
bool has_eu_ce:1;
//! EU WEEE Mark (wastebin with X)
bool has_eu_weee:1;
//! Japan TELEC (Telecom Engineering Center) [R] mark and ID
//! (Radio equipment conformity)
bool has_japan_telec_r:1;
//! TELEC mark [T] mark and ID (Terminal equipment conformity)
bool has_japan_telec_t:1;
//! Korea
//! - KCC mark
//! - Details window with KCC mark and KCC ID
bool has_korea_kcc:1;
//! Mexico NOM NYCE mark
bool has_mexico_nom_nyce:1;
//! USA FCC Mark and FCC ID
bool has_usa_fcc:1;
} RegulatoryFlags;
typedef struct CertificationIds {
const char *canada_ic_id;
const char *china_cmiit_id;
const char *japan_telec_r_id;
const char *japan_telec_t_id;
const char *korea_kcc_id;
const char *mexico_ifetel_id;
const char *usa_fcc_id;
} CertificationIds;
static const RegulatoryFlags s_regulatory_flags_fallback = {
};
// Certifiation ID strings used for bigboards and such.
static const CertificationIds s_certification_ids_fallback = {
.canada_ic_id = "XXXXXX-YYY",
.china_cmiit_id = "ABCDEFGHIJ",
.japan_telec_r_id = "XXX-YYYYYY",
.japan_telec_t_id = "D XX YYYY ZZZ",
.korea_kcc_id = "WWWW-XXX-YYY-ZZZ",
.mexico_ifetel_id = "RCPPEXXXX-YYYY",
.usa_fcc_id = "XXX-YYY",
};
static const RegulatoryFlags s_regulatory_flags_snowy = {
.has_canada_ic = true,
.has_china_cmiit = true,
.has_eu_ce = true,
.has_eu_weee = true,
.has_japan_telec_r = true,
.has_japan_telec_t = true,
.has_korea_kcc = true,
.has_usa_fcc = true,
};
static const CertificationIds s_certification_ids_snowy = {
.canada_ic_id = "10805A-501",
.china_cmiit_id = "2015DJ1504",
.japan_telec_r_id = "201-150104",
.japan_telec_t_id = "D 15 0015 201",
.korea_kcc_id = "MSIP-CRM-PEB-WQ3",
.usa_fcc_id = "RGQ-501",
};
static const CertificationIds s_certification_ids_bobby = {
.canada_ic_id = "10805A-511",
.china_cmiit_id = "2015DJ3458",
.japan_telec_r_id = "201-150257",
.japan_telec_t_id = "D 15 0065 201",
.korea_kcc_id = "MSIP-CRM-PEB-WQ3",
.usa_fcc_id = "RGQ-511",
};
static const RegulatoryFlags s_regulatory_flags_spalding = {
.has_canada_ic = true,
.has_eu_ce = true,
.has_eu_weee = true,
.has_usa_fcc = true,
};
static const CertificationIds s_certification_ids_spalding = {
.canada_ic_id = "10805A-601",
.usa_fcc_id = "RGQ-601",
};
static const RegulatoryFlags s_regulatory_flags_silk = {
.has_australia_rcm = true,
.has_canada_ic = true,
.has_china_cmiit = true,
.has_eu_ce = true,
.has_eu_weee = true,
.has_japan_telec_r = true,
.has_mexico_nom_nyce = true,
.has_usa_fcc = true,
};
static const CertificationIds s_certification_ids_silk = {
.canada_ic_id = "10805A-1001",
.china_cmiit_id = "2016DJ4469",
.usa_fcc_id = "RGQ-1001",
.japan_telec_r_id = "201-160535",
.mexico_ifetel_id = "RCPPE1016-1161"
};
static const CertificationIds s_certification_ids_silk_hr = {
.canada_ic_id = "10805A-1002",
.china_cmiit_id = "2016DJ4931",
.usa_fcc_id = "RGQ-1002",
.japan_telec_r_id = "201-160558",
.mexico_ifetel_id = "RCPPE1016-1238"
};
static const RegulatoryFlags * prv_get_regulatory_flags(void) {
#if PLATFORM_SNOWY
return &s_regulatory_flags_snowy;
#elif PLATFORM_SPALDING
return &s_regulatory_flags_spalding;
#elif PLATFORM_SILK
return &s_regulatory_flags_silk;
#else
return &s_regulatory_flags_fallback;
#endif
}
//! Don't call this function directly. Use the prv_get_*_id functions instead.
static const CertificationIds * prv_get_certification_ids(void) {
#if defined(BOARD_SNOWY_S3)
return &s_certification_ids_bobby;
#elif defined(BOARD_SNOWY_EVT) || defined(BOARD_SNOWY_EVT2) || \
defined(BOARD_SNOWY_DVT)
return &s_certification_ids_snowy;
#elif defined(BOARD_SPALDING) || defined(BOARD_SPALDING_EVT)
return &s_certification_ids_spalding;
#elif PLATFORM_SILK && !defined(IS_BIGBOARD)
// TODO: remove force-false
// if (mfg_info_is_hrm_present()) {
// return &s_certification_ids_silk_hr;
// } else {
return &s_certification_ids_silk;
// }
#else
return &s_certification_ids_fallback;
#endif
}
#define ID_GETTER(ID_KIND) \
static const char * prv_get_##ID_KIND(void) { \
return prv_get_certification_ids()->ID_KIND ?: \
s_certification_ids_fallback.ID_KIND; \
}
ID_GETTER(canada_ic_id)
ID_GETTER(china_cmiit_id)
ID_GETTER(japan_telec_r_id)
ID_GETTER(japan_telec_t_id)
ID_GETTER(korea_kcc_id)
ID_GETTER(mexico_ifetel_id)
ID_GETTER(usa_fcc_id)
#undef ID_GETTER

View file

@ -0,0 +1,283 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings_display.h"
#include "settings_display_calibration.h"
#include "settings_menu.h"
#include "settings_option_menu.h"
#include "settings_window.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/ui.h"
#include "drivers/battery.h"
#include "kernel/pbl_malloc.h"
#include "popups/notifications/notification_window.h"
#include "process_state/app_state/app_state.h"
#include "services/common/i18n/i18n.h"
#include "services/common/light.h"
#include "shell/prefs.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/size.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct SettingsDisplayData {
SettingsCallbacks callbacks;
} SettingsDisplayData;
// Intensity Settings
/////////////////////////////
static const uint32_t s_intensity_values[] = { 5, 25, 45, 70 };
static const char *s_intensity_labels[] = {
i18n_noop("Low"),
i18n_noop("Medium"),
i18n_noop("High"),
i18n_noop("Blinding")
};
#define BACKLIGHT_SCALE_GRANULARITY 5
// Normalize the result from light get brightness as it sometimes
// will round down/up by a %
static uint8_t prv_get_scaled_brightness(void) {
return BACKLIGHT_SCALE_GRANULARITY
* ((backlight_get_intensity_percent() + BACKLIGHT_SCALE_GRANULARITY - 1)
/ BACKLIGHT_SCALE_GRANULARITY);
}
static int prv_intensity_get_selection_index() {
const uint8_t intensity = prv_get_scaled_brightness();
// FIXME: PBL-22272 ... We will return idx 0 if someone has an old value for
// one of the intensity options
for (int i = 0; i < (int)ARRAY_LENGTH(s_intensity_values); i++) {
if (s_intensity_values[i] == intensity) {
return i;
}
}
return 0;
}
static void prv_intensity_menu_select(OptionMenu *option_menu, int selection, void *context) {
backlight_set_intensity_percent(s_intensity_values[selection]);
app_window_stack_remove(&option_menu->window, true /*animated*/);
}
static void prv_intensity_menu_push(SettingsDisplayData *data) {
const int index = prv_intensity_get_selection_index();
const OptionMenuCallbacks callbacks = {
.select = prv_intensity_menu_select,
};
const char *title = PBL_IF_RECT_ELSE(i18n_noop("INTENSITY"), i18n_noop("Intensity"));
settings_option_menu_push(
title, OptionMenuContentType_SingleLine, index, &callbacks, ARRAY_LENGTH(s_intensity_labels),
true /* icons_enabled */, s_intensity_labels, data);
}
// Timeout Settings
/////////////////////////////
static const uint32_t s_timeout_values[] = { 3000, 5000, 8000 };
static const char *s_timeout_labels[] = {
i18n_noop("3 Seconds"),
i18n_noop("5 Seconds"),
i18n_noop("8 Seconds")
};
static int prv_timeout_get_selection_index() {
uint32_t timeout_ms = backlight_get_timeout_ms();
for (size_t i = 0; i < ARRAY_LENGTH(s_timeout_values); i++) {
if (s_timeout_values[i] == timeout_ms) {
return i;
}
}
return 0;
}
static void prv_timeout_menu_select(OptionMenu *option_menu, int selection, void *context) {
backlight_set_timeout_ms(s_timeout_values[selection]);
app_window_stack_remove(&option_menu->window, true /* animated */);
}
static void prv_timeout_menu_push(SettingsDisplayData *data) {
int index = prv_timeout_get_selection_index();
const OptionMenuCallbacks callbacks = {
.select = prv_timeout_menu_select,
};
const char *title = PBL_IF_RECT_ELSE(i18n_noop("TIMEOUT"), i18n_noop("Timeout"));
settings_option_menu_push(
title, OptionMenuContentType_SingleLine, index, &callbacks, ARRAY_LENGTH(s_timeout_labels),
true /* icons_enabled */, s_timeout_labels, data);
}
// Menu Callbacks
/////////////////////////////
enum SettingsDisplayItem {
SettingsDisplayLanguage,
SettingsDisplayBacklightMode,
SettingsDisplayMotionSensor,
SettingsDisplayAmbientSensor,
SettingsDisplayBacklightIntensity,
SettingsDisplayBacklightTimeout,
#if PLATFORM_SPALDING
SettingsDisplayAdjustAlignment,
#endif
NumSettingsDisplayItems
};
// number of items under SettingsDisplayBacklightMode which are hidden when backlight is disabled
static const int NUM_BACKLIGHT_SUB_ITEMS = SettingsDisplayBacklightTimeout -
SettingsDisplayBacklightMode;
static bool prv_should_show_backlight_sub_items() {
return backlight_is_enabled();
}
uint16_t prv_get_item_from_row(uint16_t row) {
if (!prv_should_show_backlight_sub_items() && (row > SettingsDisplayBacklightMode)) {
return row + NUM_BACKLIGHT_SUB_ITEMS;
}
return row;
}
static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) {
SettingsDisplayData *data = (SettingsDisplayData*)context;
switch (prv_get_item_from_row(row)) {
case SettingsDisplayLanguage:
shell_prefs_toggle_language_english();
break;
case SettingsDisplayBacklightMode:
light_toggle_enabled();
break;
case SettingsDisplayMotionSensor:
backlight_set_motion_enabled(!backlight_is_motion_enabled());
break;
case SettingsDisplayAmbientSensor:
light_toggle_ambient_sensor_enabled();
break;
case SettingsDisplayBacklightIntensity:
prv_intensity_menu_push(data);
break;
case SettingsDisplayBacklightTimeout:
prv_timeout_menu_push(data);
break;
#if PLATFORM_SPALDING
case SettingsDisplayAdjustAlignment:
settings_display_calibration_push(app_state_get_window_stack());
break;
#endif
default:
WTF;
}
settings_menu_reload_data(SettingsMenuItemDisplay);
settings_menu_mark_dirty(SettingsMenuItemDisplay);
}
static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx,
const Layer *cell_layer, uint16_t row, bool selected) {
SettingsDisplayData *data = (SettingsDisplayData*) context;
const char *title = NULL;
const char *subtitle = NULL;
switch (prv_get_item_from_row(row)) {
case SettingsDisplayLanguage:
title = i18n_noop("Language");
subtitle = i18n_get_lang_name();
break;
case SettingsDisplayBacklightMode:
title = i18n_noop("Backlight");
if (backlight_is_enabled()) {
subtitle = i18n_noop("On");
} else {
subtitle = i18n_noop("Off");
}
break;
case SettingsDisplayMotionSensor:
title = i18n_noop("Motion Enabled");
if (backlight_is_motion_enabled()) {
subtitle = i18n_noop("On");
} else {
subtitle = i18n_noop("Off");
}
break;
case SettingsDisplayAmbientSensor:
title = i18n_noop("Ambient Sensor");
if (backlight_is_ambient_sensor_enabled()) {
subtitle = i18n_noop("On");
} else {
subtitle = i18n_noop("Off");
}
break;
case SettingsDisplayBacklightIntensity:
title = i18n_noop("Intensity");
subtitle = s_intensity_labels[prv_intensity_get_selection_index()];
break;
case SettingsDisplayBacklightTimeout:
title = i18n_noop("Timeout");
subtitle = s_timeout_labels[prv_timeout_get_selection_index()];
break;
#if PLATFORM_SPALDING
case SettingsDisplayAdjustAlignment:
title = i18n_noop("Screen Alignment");
break;
#endif
default:
WTF;
}
menu_cell_basic_draw(ctx, cell_layer, i18n_get(title, data), i18n_get(subtitle, data), NULL);
}
static uint16_t prv_num_rows_cb(SettingsCallbacks *context) {
if (!prv_should_show_backlight_sub_items()) {
return NumSettingsDisplayItems - NUM_BACKLIGHT_SUB_ITEMS;
}
return NumSettingsDisplayItems;
}
static void prv_deinit_cb(SettingsCallbacks *context) {
SettingsDisplayData *data = (SettingsDisplayData*) context;
i18n_free_all(data);
app_free(data);
}
static Window *prv_init(void) {
SettingsDisplayData *data = app_malloc_check(sizeof(*data));
*data = (SettingsDisplayData){};
data->callbacks = (SettingsCallbacks) {
.deinit = prv_deinit_cb,
.draw_row = prv_draw_row_cb,
.select_click = prv_select_click_cb,
.num_rows = prv_num_rows_cb,
};
return settings_window_create(SettingsMenuItemDisplay, &data->callbacks);
}
const SettingsModuleMetadata *settings_display_get_info(void) {
static const SettingsModuleMetadata s_module_info = {
.name = i18n_noop("Display"),
.init = prv_init,
};
return &s_module_info;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "settings_menu.h"
const SettingsModuleMetadata *settings_display_get_info(void);

View file

@ -0,0 +1,313 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#if PLATFORM_SPALDING
#include "settings_display_calibration.h"
#include "applib/fonts/fonts.h"
#include "applib/graphics/graphics.h"
#include "applib/graphics/text.h"
#include "util/trig.h"
#include "applib/ui/window.h"
#include "applib/ui/window_stack.h"
#include "kernel/pbl_malloc.h"
#include "resource/resource_ids.auto.h"
#include "services/common/analytics/analytics.h"
#include "services/common/i18n/i18n.h"
#include "services/common/light.h"
#include "shell/prefs.h"
#include "system/passert.h"
#include "util/math.h"
static const int16_t MAX_OFFSET_MAGNITUDE = 10;
typedef enum {
DisplayCalibrationState_X_Adjust,
DisplayCalibrationState_Y_Adjust,
DisplayCalibrationState_Confirm
} DisplayCalibrationState;
static const DisplayCalibrationState INITIAL_STATE = DisplayCalibrationState_X_Adjust;
typedef struct {
Window window;
Layer layer;
DisplayCalibrationState state;
GPoint offset;
GBitmap arrow_down;
GBitmap arrow_left;
GBitmap arrow_up;
GBitmap arrow_right;
} DisplayCalibrationData;
static void prv_draw_text(Layer *layer, GContext *ctx) {
DisplayCalibrationData *data = window_get_user_data(layer_get_window(layer));
graphics_context_set_text_color(ctx, GColorWhite);
const char *titles[] = {
[DisplayCalibrationState_X_Adjust] = i18n_noop("Horizontal Alignment"),
[DisplayCalibrationState_Y_Adjust] = i18n_noop("Vertical Alignment"),
[DisplayCalibrationState_Confirm] = i18n_noop("Confirm Alignment")
};
const char *instructions[] = {
[DisplayCalibrationState_X_Adjust] = i18n_noop("Up/Down to adjust\nSelect to proceed"),
[DisplayCalibrationState_Y_Adjust] = i18n_noop("Up/Down to adjust\nSelect to proceed"),
[DisplayCalibrationState_Confirm] = i18n_noop("Select to confirm alignment changes")
};
const char *title_text = i18n_get(titles[data->state], data);
const char *instruction_text = i18n_get(instructions[data->state], data);
const GTextOverflowMode overflow_mode = GTextOverflowModeTrailingEllipsis;
const GTextAlignment text_alignment = GTextAlignmentCenter;
const GFont title_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
const GFont instruction_font = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
const int16_t title_line_height = fonts_get_font_height(title_font);
const int16_t text_margin_x = 16;
const int16_t text_margin_y = 32;
const GRect max_text_container_frame = grect_inset_internal(layer->bounds,
text_margin_x, text_margin_y);
GRect title_frame = (GRect) {
.size = GSize(max_text_container_frame.size.w, title_line_height)
};
GRect instruction_frame = (GRect) {
.size = GSize(max_text_container_frame.size.w,
max_text_container_frame.size.h - title_line_height)
};
instruction_frame.size = graphics_text_layout_get_max_used_size(ctx, instruction_text,
instruction_font,
instruction_frame,
overflow_mode, text_alignment,
NULL);
GRect text_container_frame = (GRect) {
.size = GSize(max_text_container_frame.size.w, title_frame.size.h + instruction_frame.size.h)
};
const bool clips = true;
grect_align(&text_container_frame, &max_text_container_frame, GAlignCenter, clips);
grect_align(&title_frame, &text_container_frame, GAlignTop, clips);
grect_align(&instruction_frame, &text_container_frame, GAlignBottom, clips);
const int16_t title_vertical_adjust_px = -fonts_get_font_cap_offset(title_font);
title_frame.origin.y += title_vertical_adjust_px;
graphics_draw_text(ctx, title_text, title_font, title_frame,
overflow_mode, text_alignment, NULL);
graphics_draw_text(ctx, instruction_text, instruction_font,
instruction_frame, overflow_mode, text_alignment, NULL);
}
void prv_draw_border_stripe(Layer *layer, GContext *ctx, GAlign alignment) {
const int16_t stripe_inset = 6;
const int16_t stripe_width = 2;
const GRect *layer_bounds = &layer->bounds;
const bool is_horizontal = ((alignment == GAlignTop) || (alignment == GAlignBottom));
GRect rect = (GRect) {
.size = (is_horizontal) ? GSize(layer_bounds->size.w, stripe_width)
: GSize(stripe_width, layer_bounds->size.h)
};
for (int i = stripe_inset - stripe_width; i >= -MAX_OFFSET_MAGNITUDE; i -= stripe_width) {
// alternate yellow and red stripes
graphics_context_set_stroke_color(ctx, (i % (2 * stripe_width)) ? GColorRed : GColorYellow);
const GRect outer_bounds = grect_inset_internal(*layer_bounds, i, i);
grect_align(&rect, &outer_bounds, alignment, false);
graphics_draw_rect(ctx, &rect);
}
}
static void prv_draw_border_stripes(Layer *layer, GContext *ctx) {
DisplayCalibrationData *data = window_get_user_data(layer_get_window(layer));
if ((data->state == DisplayCalibrationState_X_Adjust) ||
(data->state == DisplayCalibrationState_Confirm)) {
prv_draw_border_stripe(layer, ctx, GAlignLeft);
prv_draw_border_stripe(layer, ctx, GAlignRight);
}
if ((data->state == DisplayCalibrationState_Y_Adjust) ||
(data->state == DisplayCalibrationState_Confirm)) {
prv_draw_border_stripe(layer, ctx, GAlignTop);
prv_draw_border_stripe(layer, ctx, GAlignBottom);
}
}
static void prv_draw_arrow(Layer *layer, GContext *ctx, GBitmap *arrow_bitmap, GAlign alignment) {
graphics_context_set_compositing_mode(ctx, GCompOpSet);
const int16_t margin = 8;
const GRect bounds = grect_inset_internal(layer->bounds, margin, margin);
GRect box = arrow_bitmap->bounds;
grect_align(&box, &bounds, alignment, true);
graphics_draw_bitmap_in_rect(ctx, arrow_bitmap, &box);
}
static void prv_draw_arrows(Layer *layer, GContext *ctx) {
DisplayCalibrationData *data = window_get_user_data(layer_get_window(layer));
switch (data->state) {
case DisplayCalibrationState_X_Adjust:
if (data->offset.x > -MAX_OFFSET_MAGNITUDE) {
prv_draw_arrow(layer, ctx, &data->arrow_left, GAlignLeft);
}
if (data->offset.x < MAX_OFFSET_MAGNITUDE) {
prv_draw_arrow(layer, ctx, &data->arrow_right, GAlignRight);
}
break;
case DisplayCalibrationState_Y_Adjust:
if (data->offset.y > -MAX_OFFSET_MAGNITUDE) {
prv_draw_arrow(layer, ctx, &data->arrow_up, GAlignTop);
}
if (data->offset.y < MAX_OFFSET_MAGNITUDE) {
prv_draw_arrow(layer, ctx, &data->arrow_down, GAlignBottom);
}
break;
case DisplayCalibrationState_Confirm:
break;
}
}
static void prv_layer_update_proc(Layer *layer, GContext *ctx) {
DisplayCalibrationData *data = window_get_user_data(layer_get_window(layer));
ctx->draw_state.drawing_box.origin.x += data->offset.x;
ctx->draw_state.drawing_box.origin.y += data->offset.y;
prv_draw_border_stripes(layer, ctx);
prv_draw_text(layer, ctx);
prv_draw_arrows(layer, ctx);
}
static void prv_select_click_handler(ClickRecognizerRef recognizer, void *context) {
DisplayCalibrationData *data = context;
if (data->state == DisplayCalibrationState_Confirm) {
// set a new user offset
shell_prefs_set_display_offset(data->offset);
analytics_inc(ANALYTICS_DEVICE_METRIC_DISPLAY_OFFSET_MODIFIED_COUNT, AnalyticsClient_System);
window_stack_remove(&data->window, true /* animated */);
return;
}
data->state++;
layer_mark_dirty(&data->window.layer);
}
static void prv_back_click_handler(ClickRecognizerRef recognizer, void *context) {
DisplayCalibrationData *data = context;
if (data->state == INITIAL_STATE) {
// exit the calibration window without changing the prefs
window_stack_remove(&data->window, true /* animated */);
return;
}
data->state--;
layer_mark_dirty(&data->window.layer);
}
static void prv_up_down_click_handler(ClickRecognizerRef recognizer, void *context) {
DisplayCalibrationData *data = context;
int to_add = (click_recognizer_get_button_id(recognizer) == BUTTON_ID_UP) ? -1 : 1;
switch (data->state) {
case DisplayCalibrationState_X_Adjust:
data->offset.x = CLIP(data->offset.x + to_add, -MAX_OFFSET_MAGNITUDE, MAX_OFFSET_MAGNITUDE);
break;
case DisplayCalibrationState_Y_Adjust:
data->offset.y = CLIP(data->offset.y + to_add, -MAX_OFFSET_MAGNITUDE, MAX_OFFSET_MAGNITUDE);
break;
case DisplayCalibrationState_Confirm:
break;
}
layer_mark_dirty(&data->window.layer);
}
static void prv_config_provider(void *data) {
const uint16_t interval_ms = 50;
window_single_repeating_click_subscribe(BUTTON_ID_UP, interval_ms, prv_up_down_click_handler);
window_single_repeating_click_subscribe(BUTTON_ID_DOWN, interval_ms, prv_up_down_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, prv_select_click_handler);
window_single_click_subscribe(BUTTON_ID_BACK, prv_back_click_handler);
}
void prv_window_unload(struct Window *window) {
DisplayCalibrationData *data = window_get_user_data(window);
light_reset_user_controlled();
gbitmap_deinit(&data->arrow_down);
gbitmap_deinit(&data->arrow_left);
gbitmap_deinit(&data->arrow_up);
gbitmap_deinit(&data->arrow_right);
// reinitialize display offset now that values may have changed
shell_prefs_display_offset_init();
layer_deinit(&data->layer);
i18n_free_all(data);
task_free(data);
}
static void prv_init_arrow_bitmap(GBitmap *bitmap, uint32_t resource_id) {
gbitmap_init_with_resource(bitmap, resource_id);
// tint cyan
PBL_ASSERTN(bitmap->info.format == GBitmapFormat2BitPalette);
unsigned int palette_size = gbitmap_get_palette_size(bitmap->info.format);
GColor *palette = task_zalloc_check(sizeof(GColor) * palette_size);
for (unsigned int i = 0; i < palette_size; i++) {
palette[i].argb = (bitmap->palette[i].argb & 0xc0) | (GColorCyan.argb & 0x3f);
}
gbitmap_set_palette(bitmap, palette, true /* free on destroy */);
}
void settings_display_calibration_push(WindowStack *window_stack) {
DisplayCalibrationData *data = task_zalloc_check(sizeof(DisplayCalibrationData));
*data = (DisplayCalibrationData) {
.offset = shell_prefs_get_display_offset()
};
shell_prefs_set_should_prompt_display_calibration(false);
display_set_offset(GPointZero);
light_enable(true);
Window *window = &data->window;
window_init(window, "SettingsDisplayCalibration");
window_set_click_config_provider_with_context(window, prv_config_provider, data);
window_set_user_data(window, data);
window_set_window_handlers(window, &(WindowHandlers) { .unload = prv_window_unload });
window_set_background_color(window, GColorBlack);
Layer *root_layer = window_get_root_layer(window);
Layer *layer = &data->layer;
layer_init(layer, &root_layer->bounds);
layer_set_update_proc(layer, prv_layer_update_proc);
layer_add_child(root_layer, layer);
prv_init_arrow_bitmap(&data->arrow_down, RESOURCE_ID_ARROW_DOWN);
prv_init_arrow_bitmap(&data->arrow_left, RESOURCE_ID_ARROW_LEFT);
prv_init_arrow_bitmap(&data->arrow_up, RESOURCE_ID_ARROW_UP);
prv_init_arrow_bitmap(&data->arrow_right, RESOURCE_ID_ARROW_RIGHT);
window_stack_push(window_stack, window, true /* animated */);
}
#endif

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
// forward declaration
struct WindowStack;
typedef struct WindowStack WindowStack;
void settings_display_calibration_push(WindowStack *window_stack);

View file

@ -0,0 +1,191 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings_factory_reset.h"
#include "applib/app_timer.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/action_bar_layer.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/window_stack_private.h"
#include "applib/ui/ui.h"
#include "apps/system_apps/timeline/peek_layer.h"
#include "kernel/pbl_malloc.h"
#include "kernel/ui/kernel_ui.h"
#include "kernel/ui/system_icons.h"
#include "kernel/util/factory_reset.h"
#include "resource/resource_ids.auto.h"
#include "services/common/i18n/i18n.h"
#include "services/common/system_task.h"
#include "settings_bluetooth.h"
#include "system/logging.h"
#define MESSAGE_BUF_SIZE 96
typedef struct ConfirmUIData {
Window window;
ActionBarLayer action_bar;
TextLayer msg_text_layer;
TextLayer forget_text_layer;
PeekLayer resetting_layer;
char msg_text_layer_buffer[MESSAGE_BUF_SIZE];
GBitmap *action_bar_icon_check;
GBitmap *action_bar_icon_x;
} ConfirmUIData;
//! Wipe registry + Reboot
static void start_factory_reset(void *data) {
factory_reset(false /* should_shutdown */);
}
static void prv_lockout_back_button(Window *window) {
window_set_overrides_back_button(window, true);
window_set_click_config_provider(window, NULL);
}
static void confirm_click_handler(ClickRecognizerRef recognizer, Window *window) {
ConfirmUIData *data = window_get_user_data(window);
// Need to lock-out inputs after starting the factory reset.
prv_lockout_back_button(window);
PeekLayer *peek_layer = &data->resetting_layer;
peek_layer_init(peek_layer, &window->layer.bounds);
peek_layer_set_title_font(peek_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
TimelineResourceInfo timeline_res = {
.res_id = TIMELINE_RESOURCE_GENERIC_WARNING,
};
peek_layer_set_icon(peek_layer, &timeline_res);
peek_layer_set_title(peek_layer, i18n_get("Resetting...", data));
peek_layer_set_background_color(peek_layer, PBL_IF_COLOR_ELSE(GColorLightGray, GColorWhite));
peek_layer_play(peek_layer);
layer_add_child(&window->layer, &peek_layer->layer);
// give it a chance to animate
const uint32_t factory_reset_start_delay = 100;
app_timer_register(PEEK_LAYER_UNFOLD_DURATION + factory_reset_start_delay,
start_factory_reset, NULL);
}
//! Wipe registry + Enter Standby (for factory)
static void confirm_long_click_handler(ClickRecognizerRef recognizer, Window *window) {
// Need to lock-out inputs after starting the factory reset.
prv_lockout_back_button(window);
factory_reset(true /* should_shutdown */);
}
static void decline_click_handler(ClickRecognizerRef recognizer, Window *window) {
const bool animated = true;
app_window_stack_pop(animated);
(void)recognizer;
(void)window;
}
static void config_provider(Window *window) {
window_single_click_subscribe(BUTTON_ID_UP, (ClickHandler) confirm_click_handler);
window_long_click_subscribe(BUTTON_ID_UP, 1200, (ClickHandler) confirm_long_click_handler, NULL);
window_single_click_subscribe(BUTTON_ID_DOWN, (ClickHandler) decline_click_handler);
(void)window;
}
static void prv_window_load(Window *window) {
ConfirmUIData *data = window_get_user_data(window);
const GRect *root_layer_bounds = &window_get_root_layer(window)->bounds;
const int16_t width = root_layer_bounds->size.w - ACTION_BAR_WIDTH;
const uint16_t x_margin_px = PBL_IF_ROUND_ELSE(6, 3);
const uint16_t msg_text_y_offset_px = PBL_IF_ROUND_ELSE(15, 0);
const uint16_t msg_text_max_height_px = root_layer_bounds->size.h - msg_text_y_offset_px;
const GTextAlignment alignment = PBL_IF_ROUND_ELSE(GTextAlignmentRight, GTextAlignmentLeft);
const GTextOverflowMode overflow_mode = GTextOverflowModeTrailingEllipsis;
const GColor text_color = PBL_IF_COLOR_ELSE(GColorWhite, GColorBlack);
TextLayer *msg_text_layer = &data->msg_text_layer;
GRect msg_text_frame = (GRect) {
.origin = GPoint(x_margin_px, msg_text_y_offset_px),
.size = GSize(width - (2 * x_margin_px), msg_text_max_height_px)
};
text_layer_init_with_parameters(msg_text_layer, &msg_text_frame,
i18n_get("Perform factory reset?", data),
fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD), text_color,
GColorClear, alignment, overflow_mode);
layer_add_child(&window->layer, &msg_text_layer->layer);
#if PBL_ROUND
const uint8_t text_flow_inset = 8;
text_layer_enable_screen_text_flow_and_paging(msg_text_layer, text_flow_inset);
#endif
// handle different title heights gracefully
GContext *ctx = graphics_context_get_current_context();
const uint16_t msg_text_height_px = text_layer_get_content_size(ctx, msg_text_layer).h;
const int text_spacing = 7;
const uint16_t forget_text_y_offset_px = msg_text_y_offset_px + msg_text_height_px + text_spacing;
TextLayer *forget_text_layer = &data->forget_text_layer;
const GRect forget_text_frame = (GRect) {
.origin = GPoint(x_margin_px, forget_text_y_offset_px),
.size = GSize(width - (2 * x_margin_px), root_layer_bounds->size.h - forget_text_y_offset_px)
};
text_layer_init_with_parameters(forget_text_layer, &forget_text_frame,
i18n_get(BT_FORGET_PAIRING_STR, data),
fonts_get_system_font(FONT_KEY_GOTHIC_18), text_color,
GColorClear, alignment, overflow_mode);
layer_add_child(&window->layer, &forget_text_layer->layer);
#if PBL_ROUND
text_layer_enable_screen_text_flow_and_paging(forget_text_layer, text_flow_inset);
#endif
// Action bar:
ActionBarLayer *action_bar = &data->action_bar;
action_bar_layer_init(action_bar);
action_bar_layer_set_context(action_bar, window);
action_bar_layer_add_to_window(action_bar, window);
action_bar_layer_set_click_config_provider(action_bar, (ClickConfigProvider) config_provider);
action_bar_layer_set_icon(action_bar, BUTTON_ID_UP, data->action_bar_icon_check);
action_bar_layer_set_icon(action_bar, BUTTON_ID_DOWN, data->action_bar_icon_x);
}
static void prv_window_unload(Window *window) {
ConfirmUIData *data = window_get_user_data(window);
gbitmap_destroy(data->action_bar_icon_check);
gbitmap_destroy(data->action_bar_icon_x);
i18n_free_all(data);
app_free(data);
}
void settings_factory_reset_window_push(void) {
ConfirmUIData *data = (ConfirmUIData*)app_malloc_check(sizeof(ConfirmUIData));
*data = (ConfirmUIData){};
Window *window = &data->window;
window_init(window, "Settings Factory Reset");
window_set_window_handlers(window, &(WindowHandlers) {
.load = prv_window_load,
.unload = prv_window_unload,
});
#if PBL_COLOR
window_set_background_color(window, GColorCobaltBlue);
#endif
window_set_user_data(window, data);
data->action_bar_icon_check = gbitmap_create_with_resource(RESOURCE_ID_ACTION_BAR_ICON_CHECK);
data->action_bar_icon_x = gbitmap_create_with_resource(RESOURCE_ID_ACTION_BAR_ICON_X);
const bool animated = true;
app_window_stack_push(window, animated);
}
#undef MESSAGE_BUF_SIZE

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "kernel/events.h"
void settings_factory_reset_window_push();

View file

@ -0,0 +1,73 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings_activity_tracker.h"
#include "settings_bluetooth.h"
#include "settings_display.h"
#include "settings_menu.h"
#include "settings_notifications.h"
#include "settings_quick_launch.h"
#include "settings_quiet_time.h"
#include "settings_remote.h"
#include "settings_system.h"
#include "settings_time.h"
#include "settings_timeline.h"
#if CAPABILITY_HAS_VIBE_SCORES
#include "settings_vibe_patterns.h"
#endif
#include "applib/ui/app_window_stack.h"
#include "services/common/i18n/i18n.h"
#include "system/passert.h"
static const SettingsModuleGetMetadata s_submodule_registry[] = {
[SettingsMenuItemBluetooth] = settings_bluetooth_get_info,
[SettingsMenuItemNotifications] = settings_notifications_get_info,
#if CAPABILITY_HAS_VIBE_SCORES
[SettingsMenuItemVibrations] = settings_vibe_patterns_get_info,
#endif
[SettingsMenuItemQuietTime] = settings_quiet_time_get_info,
#if CAPABILITY_HAS_TIMELINE_PEEK
[SettingsMenuItemTimeline] = settings_timeline_get_info,
#endif
#if !TINTIN_FORCE_FIT
[SettingsMenuItemActivity] = settings_activity_tracker_get_info,
[SettingsMenuItemQuickLaunch] = settings_quick_launch_get_info,
[SettingsMenuItemDateTime] = settings_time_get_info,
#else
[SettingsMenuItemActivity] = settings_system_get_info,
[SettingsMenuItemQuickLaunch] = settings_system_get_info,
[SettingsMenuItemDateTime] = settings_system_get_info,
#endif
[SettingsMenuItemDisplay] = settings_display_get_info,
[SettingsMenuItemSystem] = settings_system_get_info,
};
const SettingsModuleMetadata *settings_menu_get_submodule_info(SettingsMenuItem category) {
PBL_ASSERTN(category < SettingsMenuItem_Count);
return s_submodule_registry[category]();
}
const char *settings_menu_get_status_name(SettingsMenuItem category) {
const SettingsModuleMetadata *info = settings_menu_get_submodule_info(category);
return info->name;
}
void settings_menu_push(SettingsMenuItem category) {
Window *window = settings_menu_get_submodule_info(category)->init();
app_window_stack_push(window, true /* animated */);
}

View file

@ -0,0 +1,95 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once
#include "applib/graphics/gtypes.h"
#include "applib/ui/layer.h"
#include "applib/ui/window.h"
#include <stdint.h>
#define SETTINGS_MENU_HIGHLIGHT_COLOR PBL_IF_COLOR_ELSE(GColorCobaltBlue, GColorBlack)
#define SETTINGS_MENU_TITLE_NORMAL_COLOR PBL_IF_COLOR_ELSE(GColorDarkGray, GColorBlack)
typedef enum {
SettingsMenuItemBluetooth = 0,
SettingsMenuItemNotifications,
#if CAPABILITY_HAS_VIBE_SCORES
SettingsMenuItemVibrations,
#endif
SettingsMenuItemQuietTime,
#if CAPABILITY_HAS_TIMELINE_PEEK
SettingsMenuItemTimeline,
#endif
SettingsMenuItemQuickLaunch,
SettingsMenuItemDateTime,
SettingsMenuItemDisplay,
SettingsMenuItemActivity,
SettingsMenuItemSystem,
SettingsMenuItem_Count,
SettingsMenuItem_Invalid
} SettingsMenuItem;
struct SettingsCallbacks;
typedef struct SettingsCallbacks SettingsCallbacks;
typedef void (*SettingsDeinit)(SettingsCallbacks *context);
typedef uint16_t (*SettingsGetInitialSelection)(SettingsCallbacks *context);
typedef void (*SettingsSelectionChangedCallback)(SettingsCallbacks *context, uint16_t new_row,
uint16_t old_row);
typedef void (*SettingsSelectionWillChangeCallback)(SettingsCallbacks *context, uint16_t *new_row,
uint16_t old_row);
typedef void (*SettingsSelectClickCallback)(SettingsCallbacks *context, uint16_t row);
typedef void (*SettingsDrawRowCallback)(SettingsCallbacks *context, GContext *ctx,
const Layer *cell_layer, uint16_t row, bool selected);
typedef uint16_t (*SettingsNumRowsCallback)(SettingsCallbacks *context);
typedef int16_t (*SettingsRowHeightCallback)(SettingsCallbacks *context, uint16_t row,
bool is_selected);
typedef void (*SettingsExpandCallback)(SettingsCallbacks *context);
typedef void (*SettingsAppearCallback)(SettingsCallbacks *context);
typedef void (*SettingsHideCallback)(SettingsCallbacks *context);
struct SettingsCallbacks {
SettingsDeinit deinit;
SettingsDrawRowCallback draw_row;
SettingsGetInitialSelection get_initial_selection;
SettingsSelectionChangedCallback selection_changed;
SettingsSelectionWillChangeCallback selection_will_change;
SettingsSelectClickCallback select_click;
SettingsNumRowsCallback num_rows;
SettingsRowHeightCallback row_height;
SettingsExpandCallback expand;
SettingsAppearCallback appear;
SettingsHideCallback hide;
};
typedef Window *(*SettingsInitFunction)(void);
typedef struct {
const char *name;
SettingsInitFunction init;
} SettingsModuleMetadata;
typedef const SettingsModuleMetadata *(*SettingsModuleGetMetadata)(void);
void settings_menu_mark_dirty(SettingsMenuItem category);
void settings_menu_reload_data(SettingsMenuItem category);
int16_t settings_menu_get_selected_row(SettingsMenuItem category);
const SettingsModuleMetadata *settings_menu_get_submodule_info(SettingsMenuItem category);
const char *settings_menu_get_status_name(SettingsMenuItem category);
void settings_menu_push(SettingsMenuItem category);

View file

@ -0,0 +1,390 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "settings_notifications_private.h"
#include "settings_menu.h"
#include "settings_option_menu.h"
#include "settings_window.h"
#include "applib/event_service_client.h"
#include "applib/fonts/fonts.h"
#include "applib/ui/action_menu_window_private.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/option_menu_window.h"
#include "applib/ui/ui.h"
#include "drivers/battery.h"
#include "kernel/pbl_malloc.h"
#include "popups/notifications/notification_window.h"
#include "services/common/analytics/analytics.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/notifications/alerts_preferences_private.h"
#include "services/normal/notifications/alerts_private.h"
#include "services/normal/vibes/vibe_intensity.h"
#include "shell/prefs.h"
#include "shell/system_theme.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/size.h"
#include "util/time/time.h"
#include <stdio.h>
// Offset between vibe intensity menu item index and vibe intensity enum values
#define INTENSITY_ROW_OFFSET 1
typedef struct {
SettingsCallbacks callbacks;
EventServiceInfo battery_connection_event_info;
} SettingsNotificationsData;
enum NotificationsItem {
NotificationsItemFilter,
#if !CAPABILITY_HAS_VIBE_SCORES
NotificationsItemVibration,
#endif
NotificationsItemTextSize,
NotificationsItemWindowTimeout,
NotificationsItem_Count,
};
// Filter Alerts
//////////////////////////
#define NUM_ALERT_MODES_IN_LIST 3
// These aren't all of the values of AlertMask, so to add extra ones you have to update both of
// these arrays
static const AlertMask s_alert_mode_values[NUM_ALERT_MODES_IN_LIST] = {
AlertMaskAllOn,
AlertMaskPhoneCalls,
AlertMaskAllOff,
};
static const char *s_alert_mode_labels[NUM_ALERT_MODES_IN_LIST] = {
i18n_noop("Allow All Notifications"),
i18n_noop("Allow Phone Calls Only"),
i18n_noop("Mute All Notifications"),
};
static const char *prv_alert_mask_to_label(AlertMask mask) {
for (uint32_t i = 0; i < NUM_ALERT_MODES_IN_LIST; i++) {
if (s_alert_mode_values[i] == mask) {
return s_alert_mode_labels[i];
}
}
return "???";
}
static void prv_filter_menu_select(OptionMenu *option_menu, int selection, void *context) {
alerts_set_mask(s_alert_mode_values[selection]);
app_window_stack_remove(&option_menu->window, true /* animated */);
}
static void prv_filter_menu_push(SettingsNotificationsData *data) {
AlertMask mask = alerts_get_mask();
size_t cycle_len = ARRAY_LENGTH(s_alert_mode_values);
size_t index = 0;
// TODO PBL-24306: update once AlertMask logic is made safer
for (size_t i = 0; i < cycle_len; i++) {
if (s_alert_mode_values[i] == mask) {
index = i;
break;
}
}
const OptionMenuCallbacks callbacks = {
.select = prv_filter_menu_select,
};
/// The option in the Settings app for filtering notifications by type.
const char *title = i18n_noop("Filter");
settings_option_menu_push(
title, OptionMenuContentType_DoubleLine, index, &callbacks, cycle_len,
true /* icons_enabled */, s_alert_mode_labels, data);
}
// Vibe Settings (If vibes scores disabled for this model)
//////////////////////////
#if !CAPABILITY_HAS_VIBE_SCORES
static const char *strings_for_vibe_intensities[] = {
i18n_ctx_noop("NotifVibe", "Disabled"),
i18n_ctx_noop("NotifVibe", "Low"),
i18n_ctx_noop("NotifVibe", "Medium"),
i18n_ctx_noop("NotifVibe", "High")
};
static void prv_vibe_menu_select(OptionMenu *option_menu, int selection, void *context) {
const bool enable_vibration = (selection != 0);
const VibeIntensity new_vibe_intensity = enable_vibration ? (selection - INTENSITY_ROW_OFFSET) :
DEFAULT_VIBE_INTENSITY;
alerts_set_vibrate(enable_vibration);
alerts_preferences_set_vibe_intensity(new_vibe_intensity);
vibe_intensity_set(new_vibe_intensity);
if (enable_vibration) {
vibes_short_pulse();
}
app_window_stack_remove(&option_menu->window, true /* animated */);
}
static void prv_vibe_menu_push(SettingsNotificationsData *data) {
const OptionMenuCallbacks callbacks = {
.select = prv_vibe_menu_select,
};
/// The option in the Settings app for choosing a vibration intensity for notifications.
const char *title = i18n_noop("Vibration");
uint32_t selected = vibe_intensity_get() + INTENSITY_ROW_OFFSET;
if (!alerts_get_vibrate()) {
selected = 0;
}
settings_option_menu_push(
title, OptionMenuContentType_SingleLine, selected, &callbacks,
ARRAY_LENGTH(strings_for_vibe_intensities), true /* icons_enabled */,
strings_for_vibe_intensities, data);
}
#endif /* !CAPABILITY_HAS_VIBE_SCORES */
// Text Size
////////////////////////
static const char *s_text_size_names[] = {
[SettingsContentSize_Small] = i18n_noop("Smaller"),
[SettingsContentSize_Default] = i18n_noop("Default"),
[SettingsContentSize_Large] = i18n_noop("Larger"),
};
static void prv_text_size_menu_select(OptionMenu *option_menu, int selection, void *context) {
system_theme_set_content_size(settings_content_size_to_preferred_size(selection));
app_window_stack_remove(&option_menu->window, true /* animated */);
}
static void prv_text_size_menu_push(SettingsNotificationsData *data) {
const OptionMenuCallbacks callbacks = {
.select = prv_text_size_menu_select,
};
/// The option in the Settings app for choosing the text size of notifications.
const char *title = i18n_noop("Text Size");
const SettingsContentSize index =
settings_content_size_from_preferred_size(system_theme_get_content_size());
settings_option_menu_push(
title, OptionMenuContentType_SingleLine, index, &callbacks, SettingsContentSizeCount,
true /* icons_enabled */, s_text_size_names, data);
}
// Text Size
////////////////////////
// NOTE: Keep the following two arrays in sync and with the same size.
static const uint32_t s_window_timeouts_ms[] = {
15 * MS_PER_SECOND,
1 * MS_PER_MINUTE,
NOTIF_WINDOW_TIMEOUT_DEFAULT,
10 * MS_PER_MINUTE,
NOTIF_WINDOW_TIMEOUT_INFINITE
};
static const char *s_window_timeouts_labels[] = {
/// 15 Second Notification Window Timeout
i18n_noop("15 Seconds"),
/// 1 Minute Notification Window Timeout
i18n_noop("1 Minute"),
/// 3 Minute Notification Window Timeout
i18n_noop("3 Minutes"),
/// 10 Minute Notification Window Timeout
i18n_noop("10 Minutes"),
/// No Notification Window Timeout
i18n_noop("None"),
};
_Static_assert(ARRAY_LENGTH(s_window_timeouts_ms) == ARRAY_LENGTH(s_window_timeouts_labels), "");
static int prv_window_timeout_get_selection_index(void) {
const int DEFAULT_IDX = 2;
// Double check no one has fudged with the order and the fallback/default
PBL_ASSERTN(s_window_timeouts_ms[DEFAULT_IDX] == NOTIF_WINDOW_TIMEOUT_DEFAULT);
const uint32_t timeout_ms = alerts_preferences_get_notification_window_timeout_ms();
for (size_t i = 0; i < ARRAY_LENGTH(s_window_timeouts_ms); i++) {
if (s_window_timeouts_ms[i] == timeout_ms) {
return i;
}
}
// Should never happen (only should happen if we remove a timeout and don't migrate the user
// to a new setting automatically
return DEFAULT_IDX;
}
static void prv_window_timeout_menu_select(OptionMenu *option_menu, int selection, void *context) {
alerts_preferences_set_notification_window_timeout_ms(s_window_timeouts_ms[selection]);
app_window_stack_remove(&option_menu->window, true /* animated */);
}
static void prv_window_timeout_menu_push(SettingsNotificationsData *data) {
const int index = prv_window_timeout_get_selection_index();
const OptionMenuCallbacks callbacks = {
.select = prv_window_timeout_menu_select,
};
/// Status bar title for the Notification Window Timeout settings screen
const char *title = i18n_noop("Timeout");
settings_option_menu_push(
title, OptionMenuContentType_SingleLine, index, &callbacks,
ARRAY_LENGTH(s_window_timeouts_labels), true /* icons_enabled */, s_window_timeouts_labels,
data);
}
// Menu Layer Callbacks
////////////////////////
static uint16_t prv_num_rows_cb(SettingsCallbacks *context) {
return NotificationsItem_Count;
}
static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx,
const Layer *cell_layer, uint16_t row, bool selected) {
SettingsNotificationsData *data = ((SettingsOptionMenuData *)context)->context;
const char *subtitle = NULL;
const char *title = NULL;
switch (row) {
case NotificationsItemFilter:
title = i18n_noop("Filter");
subtitle = prv_alert_mask_to_label(alerts_get_mask());
break;
#if !CAPABILITY_HAS_VIBE_SCORES
case NotificationsItemVibration:
title = i18n_noop("Vibration");
if (battery_is_usb_connected()) {
subtitle = i18n_noop("Disabled (Plugged In)");
} else if (alerts_get_vibrate()) {
subtitle = strings_for_vibe_intensities[vibe_intensity_get() + INTENSITY_ROW_OFFSET];
} else {
subtitle = strings_for_vibe_intensities[0];
}
break;
#endif /* !CAPABILITY_HAS_VIBE_SCORES */
case NotificationsItemTextSize: {
/// String within Settings->Notifications that describes the text font size
title = i18n_noop("Text Size");
const SettingsContentSize index =
settings_content_size_from_preferred_size(system_theme_get_content_size());
subtitle = (index < SettingsContentSizeCount) ? s_text_size_names[index] : "";
break;
}
case NotificationsItemWindowTimeout: {
/// String within Settings->Notifications that describes the window timeout setting
title = i18n_noop("Timeout");
subtitle = s_window_timeouts_labels[prv_window_timeout_get_selection_index()];
break;
}
default:
WTF;
}
menu_cell_basic_draw(ctx, cell_layer, i18n_get(title, data), i18n_get(subtitle, data), NULL);
}
static void prv_deinit_cb(SettingsCallbacks *context) {
SettingsNotificationsData *data = (SettingsNotificationsData *)context;
i18n_free_all(data);
app_free(data);
}
static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) {
SettingsNotificationsData *data = (SettingsNotificationsData *) context;
switch (row) {
case NotificationsItemFilter:
prv_filter_menu_push(data);
break;
#if !CAPABILITY_HAS_VIBE_SCORES
case NotificationsItemVibration:
if (battery_is_usb_connected()) {
return;
}
prv_vibe_menu_push(data);
break;
#endif /* !CAPABILITY_HAS_VIBE_SCORES */
case NotificationsItemTextSize:
prv_text_size_menu_push(data);
break;
case NotificationsItemWindowTimeout:
prv_window_timeout_menu_push(data);
break;
default:
WTF;
}
settings_menu_reload_data(SettingsMenuItemNotifications);
}
static void prv_settings_notifications_event_handler(PebbleEvent *event, void *context) {
switch (event->type) {
case PEBBLE_BATTERY_CONNECTION_EVENT:
// Redraw the menu so that the Vibration status will be re-rendered.
settings_menu_mark_dirty(SettingsMenuItemNotifications);
break;
default:
break;
}
}
static void prv_expand_cb(SettingsCallbacks *context) {
SettingsNotificationsData *data = (SettingsNotificationsData *) context;
data->battery_connection_event_info = (EventServiceInfo) {
.type = PEBBLE_BATTERY_CONNECTION_EVENT,
.handler = prv_settings_notifications_event_handler,
};
event_service_client_subscribe(&data->battery_connection_event_info);
}
static void prv_hide_cb(SettingsCallbacks *context) {
SettingsNotificationsData *data = (SettingsNotificationsData *) context;
event_service_client_unsubscribe(&data->battery_connection_event_info);
}
static Window *prv_init(void) {
SettingsNotificationsData* data = app_malloc_check(sizeof(*data));
*data = (SettingsNotificationsData){};
data->callbacks = (SettingsCallbacks) {
.deinit = prv_deinit_cb,
.draw_row = prv_draw_row_cb,
.select_click = prv_select_click_cb,
.num_rows = prv_num_rows_cb,
.expand = prv_expand_cb,
.hide = prv_hide_cb,
};
return settings_window_create(SettingsMenuItemNotifications, &data->callbacks);
}
const SettingsModuleMetadata *settings_notifications_get_info(void) {
static const SettingsModuleMetadata s_module_info = {
.name = i18n_noop("Notifications"),
.init = prv_init,
};
return &s_module_info;
}
void analytics_external_collect_notification_settings(void) {
const uint8_t strength = get_strength_for_intensity(vibe_intensity_get());
analytics_set(ANALYTICS_DEVICE_METRIC_SETTING_VIBRATION_STRENGTH,
strength, AnalyticsClient_System);
}

Some files were not shown because too many files have changed in this diff Show more