pebble/src/fw/apps/system_apps/timeline/timeline.c

1280 lines
45 KiB
C

/*
* 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 "pin_window.h"
#include "timeline.h"
#include "timeline_animations.h"
#include "timeline_model.h"
#include "applib/app.h"
#include "applib/ui/animation_interpolate.h"
#include "applib/ui/animation_timing.h"
#include "applib/ui/app_window_stack.h"
#include "applib/ui/kino/kino_reel/scale_segmented.h"
#include "applib/ui/kino/kino_reel/unfold.h"
#include "applib/ui/ui.h"
#include "drivers/rtc.h"
#include "kernel/event_loop.h"
#include "kernel/pbl_malloc.h"
#include "process_management/app_manager.h"
#include "resource/resource_ids.auto.h"
#include "resource/timeline_resource_ids.auto.h"
#include "services/common/analytics/analytics.h"
#include "services/common/compositor/compositor_transitions.h"
#include "services/common/i18n/i18n.h"
#include "services/normal/blob_db/pin_db.h"
#include "services/normal/timeline/actions_endpoint.h"
#include "services/normal/timeline/attribute.h"
#include "shell/normal/watchface.h"
#include "syscall/syscall.h"
#include "system/logging.h"
#include "system/passert.h"
#include "util/array.h"
#include "util/attributes.h"
#include "util/size.h"
#include "util/uuid.h"
// This is used to determine whether this app was launched as Timeline or Timeline Past.
// See timeline_get_app_info, timeline_past_get_app_info, and the usage of sys_get_app_uuid.
// uuid: DAAE3686-BFF6-4BA5-921B-262F847BB6E8
#define TIMELINE_PAST_UUID_INIT {0xDA, 0xAE, 0x36, 0x86, 0xBF, 0xF6, 0x4B, 0xA5, \
0x92, 0x1B, 0x26, 0x2F, 0x84, 0x7B, 0xB6, 0xE8}
#if PBL_ROUND || PLATFORM_TINTIN
// Tintin looks funny with the dot animation, but it results in less code space usage
#define ANIMATION_DOT 1
#define ANIMATION_SLIDE 0
#else
#define ANIMATION_DOT 0
#define ANIMATION_SLIDE 1
#endif
typedef struct TimelineAppStyle {
int16_t peek_offset_y;
int16_t peek_icon_offset_y;
} TimelineAppStyle;
static const TimelineAppStyle s_style_medium = {
.peek_icon_offset_y = PEEK_LAYER_ICON_OFFSET_Y,
};
static const TimelineAppStyle s_style_large = {
.peek_offset_y = -7,
.peek_icon_offset_y = -16,
};
static const TimelineAppStyle * const s_styles[NumPreferredContentSizes] = {
[PreferredContentSizeSmall] = &s_style_medium,
[PreferredContentSizeMedium] = &s_style_medium,
[PreferredContentSizeLarge] = &s_style_large,
[PreferredContentSizeExtraLarge] = &s_style_large,
};
static TimelineAppData *s_app_data;
static const uint32_t TIMELINE_SLIDE_ANIMATION_MS = 150;
static const uint32_t PEEK_SLIDE_ANIMATION_MS = 300;
static const uint32_t PEEK_SHOW_TIME_MS = 660;
static const TimelineAppStyle *prv_get_style(void) {
return s_styles[PreferredContentSizeDefault];
}
/////////////////////////////////////
// State Machine
/////////////////////////////////////
static bool prv_can_transition_state(TimelineAppData *data, TimelineAppState next_state) {
// all non-exit states can transition to exit
if (data->state != TimelineAppStateExit &&
next_state == TimelineAppStateExit) {
return true;
}
switch (data->state) {
case TimelineAppStateNone:
return (next_state == TimelineAppStatePeek ||
next_state == TimelineAppStateHidePeek ||
next_state == TimelineAppStateFarDayHidePeek ||
next_state == TimelineAppStateNoEvents);
case TimelineAppStatePeek:
return (next_state == TimelineAppStateHidePeek);
case TimelineAppStateHidePeek:
return (next_state == TimelineAppStateStationary);
case TimelineAppStateFarDayHidePeek:
return (next_state == TimelineAppStateDaySeparator);
case TimelineAppStateStationary:
return (next_state == TimelineAppStateUpDown ||
next_state == TimelineAppStatePushCard ||
next_state == TimelineAppStateNoEvents ||
next_state == TimelineAppStateInactive);
case TimelineAppStateUpDown:
return (next_state == TimelineAppStateUpDown ||
next_state == TimelineAppStateShowDaySeparator ||
next_state == TimelineAppStateStationary);
case TimelineAppStateShowDaySeparator:
return (next_state == TimelineAppStateDaySeparator);
case TimelineAppStateDaySeparator:
return (next_state == TimelineAppStateHideDaySeparator);
case TimelineAppStateHideDaySeparator:
return (next_state == TimelineAppStateStationary);
case TimelineAppStatePushCard:
return (next_state == TimelineAppStateCard ||
next_state == TimelineAppStatePopCard);
case TimelineAppStateCard:
return (next_state == TimelineAppStatePopCard ||
next_state == TimelineAppStateStationary);
case TimelineAppStatePopCard:
return (next_state == TimelineAppStateStationary ||
next_state == TimelineAppStatePushCard);
case TimelineAppStateNoEvents:
return (next_state == TimelineAppStateInactive);
case TimelineAppStateInactive:
case TimelineAppStateExit:
return false;
default:
WTF;
}
}
static bool prv_set_state(TimelineAppData *data, TimelineAppState next_state) {
const bool can_transition = prv_can_transition_state(data, next_state);
PBL_LOG(LOG_LEVEL_DEBUG, "state transition %d->%d valid:%d",
data->state, next_state, can_transition);
if (can_transition) {
data->state = next_state;
}
return can_transition;
}
/////////////////////////////////////
// Exit Animation & Inactivity Timer
/////////////////////////////////////
T_STATIC void prv_init_peek_layer(TimelineAppData *data);
static void prv_launch_watchface(void *data) {
#ifdef SHELL_SDK
// FIXME: We don't want to show off our unfinished animations in the sdkshell
watchface_launch_default(NULL);
#else
#if PLATFORM_TINTIN
const CompositorTransition *transition = NULL;
#else
const bool is_future = (s_app_data->timeline_model.direction == TimelineIterDirectionFuture);
const bool to_timeline = false;
const CompositorTransition *transition = PBL_IF_RECT_ELSE(
compositor_slide_transition_timeline_get(is_future, to_timeline, timeline_model_is_empty()),
compositor_dot_transition_timeline_get(is_future, to_timeline));
#endif
watchface_launch_default(transition);
#endif
}
static void prv_cleanup_timer(EventedTimerID *timer) {
if (evented_timer_exists(*timer)) {
evented_timer_cancel(*timer);
*timer = EVENTED_TIMER_INVALID_ID;
}
}
static void prv_exit_timer_callback(void *context) {
TimelineAppData *data = context;
data->timeline_layer.animating_intro_or_exit = false;
launcher_task_add_callback(prv_launch_watchface, data);
}
static void prv_intro_or_exit_anim_started(Animation *anim, void *context) {
TimelineAppData *data = context;
data->timeline_layer.animating_intro_or_exit = true;
}
static void prv_exit_anim_stopped(Animation *animation, bool finished, void *context) {
// we must use a timer to allow the last frame to render
const int exit_timeout_ms = 2 * ANIMATION_TARGET_FRAME_INTERVAL_MS;
evented_timer_register(exit_timeout_ms, false, prv_exit_timer_callback, context);
}
//! Used for setting the animation frame source and/or destination of the peek layer.
//! If use_pin is true, the animation frame size and position will be that of the first pin icon.
//! If shift_offscreen is true, the frame will be shifted by the screen row amount in a direction
//! depending on the scroll direction.
static void prv_get_icon_animation_frame(TimelineAppData *data, GRect *icon_frame_out,
bool use_pin, bool shift_offscreen) {
#if ANIMATION_DOT
const GRect *layer_frame = &data->timeline_window.layer.frame;
*icon_frame_out = (GRect) {
.origin.x = layer_frame->origin.x + (layer_frame->size.w - UNFOLD_DOT_SIZE_PX) / 2,
.origin.y = layer_frame->origin.y + (layer_frame->size.h - UNFOLD_DOT_SIZE_PX) / 2,
.size = UNFOLD_DOT_SIZE,
};
#elif ANIMATION_SLIDE
GRect icon_frame;
TimelineLayout *first_timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (first_timeline_layout && use_pin) {
GRect frame;
timeline_layer_get_layout_frame(&data->timeline_layer, TIMELINE_LAYER_FIRST_VISIBLE_LAYOUT,
&frame);
timeline_layout_get_icon_frame(&frame, data->timeline_layer.scroll_direction, &icon_frame);
} else {
// Since there is no pin, we need the peek size, which is the large size
icon_frame = (GRect) { .size = TIMELINE_LARGE_RESOURCE_SIZE };
grect_align(&icon_frame, &data->peek_layer.layer.frame, GAlignCenter, false);
const TimelineAppStyle *style = prv_get_style();
icon_frame.origin.y += style->peek_icon_offset_y;
}
if (shift_offscreen) {
if (data->timeline_model.direction == TimelineIterDirectionPast) {
icon_frame.origin.y -= DISP_ROWS;
} else {
icon_frame.origin.y += DISP_ROWS;
}
}
*icon_frame_out = icon_frame;
#endif
}
static Animation *prv_create_peek_exit_anim(TimelineAppData *data, TimelineAppState prev_state,
uint32_t duration) {
PeekLayer *peek_layer = &data->peek_layer;
if (prev_state == TimelineAppStateNoEvents ||
prev_state == TimelineAppStatePeek ||
prev_state == TimelineAppStateHidePeek) {
prv_cleanup_timer(&data->intro_timer_id);
} else if (prev_state == TimelineAppStateStationary ||
prev_state == TimelineAppStateUpDown) {
TimelineLayout *first_timeline_layout =
timeline_layer_get_current_layout(&data->timeline_layer);
if (!first_timeline_layout) {
return NULL;
}
prv_init_peek_layer(data);
GRect icon_from;
layer_get_global_frame((Layer *)&first_timeline_layout->icon_layer, &icon_from);
peek_layer_set_icon_with_size(peek_layer, &first_timeline_layout->icon_info,
TimelineResourceSizeTiny, icon_from);
} else {
return NULL;
}
GRect icon_to;
const bool use_pin = true;
const bool shift_offscreen = true;
prv_get_icon_animation_frame(data, &icon_to, use_pin, shift_offscreen);
peek_layer_clear_fields(peek_layer);
peek_layer_set_scale_to(peek_layer, icon_to);
peek_layer_set_duration(peek_layer, duration);
#if PLATFORM_TINTIN
return (Animation *)peek_layer_create_play_animation(&data->peek_layer);
#else
// Play only a section to reduce the duration to the scaling, ignoring the PDCS duration
return (Animation *)peek_layer_create_play_section_animation(&data->peek_layer, 0, duration);
#endif
}
static Animation *prv_create_sidebar_animation(TimelineAppData *data, bool open) {
int16_t to_sidebar_width;
if (open) {
to_sidebar_width = timeline_layer_get_ideal_sidebar_width();
} else {
const GRect *layer_frame = &data->timeline_window.layer.frame;
to_sidebar_width = layer_frame->size.w;
#if PBL_ROUND
// Use a larger width to ensure we fill the entire screen since we use a circular background
to_sidebar_width += 25;
#endif
}
return timeline_layer_create_sidebar_animation(&data->timeline_layer, to_sidebar_width);
}
static void prv_exit(TimelineAppData *data) {
UNUSED const TimelineAppState prev_state = data->state;
if (!prv_set_state(data, TimelineAppStateExit)) {
return;
}
#if ANIMATION_SLIDE
prv_launch_watchface(data);
#elif ANIMATION_DOT
const uint32_t duration = interpolate_moook_in_duration();
animation_unschedule(data->current_animation);
layer_remove_child_layers((Layer *)&data->timeline_layer);
Animation *sidebar_slide = prv_create_sidebar_animation(data, false /* open */);
animation_set_duration(sidebar_slide, duration);
animation_set_handlers(sidebar_slide, (AnimationHandlers) {
.started = prv_intro_or_exit_anim_started,
.stopped = prv_exit_anim_stopped,
}, data);
Animation *peek_anim = prv_create_peek_exit_anim(data, prev_state, duration);
// Just play them at the same time
animation_schedule(sidebar_slide);
if (peek_anim) {
animation_schedule(peek_anim);
}
#endif
}
static void prv_inactive_timer_callback(void *data) {
prv_set_state(data, TimelineAppStateInactive);
prv_exit(data);
}
static void prv_inactive_timer_refresh(TimelineAppData *data) {
static const uint32_t INACTIVITY_TIMEOUT_MS = 30 * 1000;
s_app_data->inactive_timer_id = evented_timer_register_or_reschedule(
s_app_data->inactive_timer_id, INACTIVITY_TIMEOUT_MS, prv_inactive_timer_callback, data);
}
/////////////////////////////////////
// Pin View
/////////////////////////////////////
static void prv_move_timeline_layer_stopped(Animation *animation, bool finished, void *context) {
TimelineAppData *data = context;
// reset the timeline layer
data->timeline_layer.layer.bounds.origin.x = 0;
window_set_background_color(&data->timeline_window, GColorWhite);
if (!finished) {
return;
}
TimelineLayout *timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (!timeline_layout) {
return;
}
// cut to the card window
app_window_stack_push(&data->pin_window.window, false);
prv_set_state(data, TimelineAppStateCard);
TimelineIterState *state = timeline_model_get_current_state();
Uuid app_uuid;
timeline_get_originator_id(&state->pin, &app_uuid);
analytics_event_pin_open(state->pin.header.timestamp, &app_uuid);
}
static Animation *prv_animate_to_pin_window(TimelineAppData *data) {
Layer *layer = &data->timeline_layer.layer;
GPoint to_origin = GPoint(-layer->bounds.size.w, 0);
Animation *animation = (Animation *)property_animation_create_bounds_origin(layer, NULL,
&to_origin);
animation_set_handlers(animation, (AnimationHandlers) {
.stopped = prv_move_timeline_layer_stopped,
}, data);
animation_set_duration(animation, TIMELINE_CARD_TRANSITION_MS / 2);
animation_set_custom_interpolation(animation, interpolate_moook);
animation_schedule(animation);
return animation;
}
static void prv_push_pin_window(TimelineAppData *data, TimelineIterState *state, bool animated) {
TimelineLayout *timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (!timeline_layout) {
return;
}
// Animation structure:
// - Scheduled simultaneously
// - Transition pin to card
// - Move timeline layer to the left
animation_unschedule(data->current_animation);
// initialize the pin window with the card layout
timeline_pin_window_init(&data->pin_window, &state->pin, state->current_day);
// match the card background color
const LayoutColors *colors = layout_get_colors((LayoutLayer *)timeline_layout);
window_set_background_color(&data->timeline_window, colors->bg_color);
// animate the card from the right
#if !PLATFORM_TINTIN
TimelineLayout *card_timeline_layout = data->pin_window.item_detail_layer.timeline_layout;
timeline_layout_transition_pin_to_card(timeline_layout, card_timeline_layout);
#endif
// animate the timeline to the left
data->current_animation = prv_animate_to_pin_window(data);
}
static bool prv_pin_in_card(TimelineAppData *data, Uuid *uuid) {
if (!app_window_stack_contains_window((Window *)&data->pin_window)) {
return false;
}
TimelineIterState *current_state = timeline_model_get_current_state();
if (current_state == NULL) {
return false;
}
return uuid_equal(&current_state->pin.header.id, uuid);
}
static void prv_refresh_pin(TimelineAppData *data, int idx) {
PBL_ASSERTN(idx >= 0);
TimelineIterState *state = timeline_model_get_iter_state(idx);
timeline_iter_refresh_pin(state);
if (idx == 0 && prv_pin_in_card(data, &state->pin.header.id)) {
timeline_pin_window_set_item(&data->pin_window, &state->pin, state->current_day);
}
}
/////////////////////////////////////
// Timeline Controller
/////////////////////////////////////
T_STATIC void prv_setup_no_events_peek(TimelineAppData *data);
static void prv_update_timeline_layer(TimelineAppData *data) {
TimelineLayer *timeline_layer = &data->timeline_layer;
if (data->state != TimelineAppStateStationary &&
data->state != TimelineAppStateUpDown &&
data->state != TimelineAppStateCard) {
return;
}
animation_unschedule(data->current_animation);
data->current_animation = NULL;
timeline_layer_reset(timeline_layer);
if (timeline_model_is_empty() &&
prv_set_state(data, TimelineAppStateNoEvents)) {
// Hide layouts and animate to "No events"
timeline_layer_set_layouts_hidden(&data->timeline_layer, true);
prv_init_peek_layer(data);
prv_setup_no_events_peek(data);
peek_layer_play(&data->peek_layer);
Animation *sidebar_slide = prv_create_sidebar_animation(data, false /* open */);
data->current_animation = sidebar_slide;
animation_schedule(sidebar_slide);
}
}
static void prv_back_click_handler(ClickRecognizerRef recognizer, void *context) {
TimelineAppData *data = context;
prv_exit(data);
}
static void prv_up_down_stopped(Animation *animation, bool finished, void *context) {
TimelineAppData *data = context;
if (finished) {
prv_set_state(data, TimelineAppStateStationary);
}
}
static void prv_hide_day_sep_stopped(Animation *animation, bool finished, void *context) {
TimelineAppData *data = context;
if (!finished || !prv_set_state(data, TimelineAppStateStationary)) {
return;
}
data->current_animation = NULL;
prv_update_timeline_layer(data);
Animation *move_animation = timeline_layer_create_up_down_animation(
&data->timeline_layer, TIMELINE_UP_DOWN_ANIMATION_DURATION_MS / 2,
timeline_animation_interpolate_moook_second_half);
animation_set_handlers(move_animation, (AnimationHandlers) {
.stopped = prv_up_down_stopped,
}, data);
data->current_animation = move_animation;
animation_schedule(move_animation);
}
static void prv_hide_day_sep(void *context) {
TimelineAppData *data = context;
data->day_separator_timer_id = EVENTED_TIMER_INVALID_ID;
if (!prv_set_state(data, TimelineAppStateHideDaySeparator)) {
return;
}
animation_unschedule(data->current_animation);
Animation *day_sep_hide = timeline_layer_create_day_sep_hide(&data->timeline_layer);
animation_set_handlers(day_sep_hide, (AnimationHandlers){
.stopped = prv_hide_day_sep_stopped,
}, data);
data->current_animation = day_sep_hide;
animation_schedule(day_sep_hide);
}
static bool prv_attempt_hide_day_sep(TimelineAppData *data) {
if (data->state == TimelineAppStateDaySeparator) {
prv_cleanup_timer(&data->day_separator_timer_id);
prv_hide_day_sep(data);
return true;
}
return false;
}
static void prv_select_click_handler(ClickRecognizerRef recognizer, void *context) {
TimelineAppData *data = context;
prv_attempt_hide_day_sep(data);
if (!prv_set_state(data, TimelineAppStatePushCard)) {
return;
}
TimelineIterState *state = timeline_model_get_current_state();
if (state) {
const bool animated = true;
prv_push_pin_window(data, state, animated);
}
}
static void prv_set_day_sep_timer(TimelineAppData *data) {
const int DAY_SEP_TIMEOUT_MS = 1000;
data->day_separator_timer_id = evented_timer_register(DAY_SEP_TIMEOUT_MS,
false,
prv_hide_day_sep,
data);
}
static void prv_day_sep_show_stopped(Animation *animation, bool finished, void *context) {
TimelineAppData *data = context;
if (!finished || !prv_set_state(data, TimelineAppStateDaySeparator)) {
return;
}
// Pins will reappear after the day separator completes hiding in `prv_hide_day_sep_stopped`
timeline_layer_set_layouts_hidden(&data->timeline_layer, true);
prv_set_day_sep_timer(data);
}
static void prv_up_down_click_handler(ClickRecognizerRef recognizer, void *context) {
TimelineAppData *data = context;
prv_inactive_timer_refresh(data);
ButtonId button = click_recognizer_get_button_id(recognizer);
const bool next = (button == BUTTON_ID_UP) ^
(data->timeline_model.direction == TimelineIterDirectionFuture);
// We want to know if it was stationary before transitioning
const bool was_stationary = (data->state == TimelineAppStateStationary);
if (data->state == TimelineAppStateNoEvents) {
if (!next) {
prv_exit(data);
}
return; // There are no events
} else if (prv_attempt_hide_day_sep(data)) {
return; // Successfully interrupted the day separator, let it hide
} else if (!prv_set_state(data, TimelineAppStateUpDown)) {
return; // Not in a state able to scroll at the moment
}
animation_unschedule(data->current_animation);
int new_idx;
bool has_new;
if (next) {
if (!timeline_model_iter_next(&new_idx, &has_new)) {
prv_set_state(data, TimelineAppStateStationary);
goto done;
}
if (has_new) {
timeline_layer_set_next_item(&data->timeline_layer, new_idx);
}
timeline_layer_move_data(&data->timeline_layer, 1);
} else {
if (!timeline_model_iter_prev(&new_idx, &has_new)) {
prv_exit(data);
goto done;
}
if (has_new) {
timeline_layer_set_prev_item(&data->timeline_layer, new_idx);
}
timeline_layer_move_data(&data->timeline_layer, -1);
}
// If we interrupted a previous scroll, hasten this scroll
const bool is_hasted = !was_stationary;
const uint32_t duration = TIMELINE_UP_DOWN_ANIMATION_DURATION_MS;
const InterpolateInt64Function interpolate = is_hasted ?
timeline_animation_interpolate_moook_second_half :
timeline_animation_interpolate_moook_soft;
Animation *move_animation = timeline_layer_create_up_down_animation(
&data->timeline_layer, duration, interpolate);
if (timeline_layer_should_animate_day_separator(&data->timeline_layer) &&
prv_set_state(data, TimelineAppStateShowDaySeparator)) {
Animation *day_sep_show = timeline_layer_create_day_sep_show(&data->timeline_layer);
move_animation = animation_spawn_create(move_animation, day_sep_show, NULL);
animation_set_handlers(move_animation, (AnimationHandlers) {
.stopped = prv_day_sep_show_stopped,
}, data);
} else {
animation_set_handlers(move_animation, (AnimationHandlers) {
.stopped = prv_up_down_stopped,
}, data);
}
data->current_animation = move_animation;
animation_schedule(move_animation);
done:
if (data->timeline_model.direction == TimelineIterDirectionPast) {
analytics_inc(ANALYTICS_DEVICE_METRIC_TIMELINE_PAST_NAVIGATION_COUNT, AnalyticsClient_System);
} else {
analytics_inc(ANALYTICS_DEVICE_METRIC_TIMELINE_FUTURE_NAVIGATION_COUNT, AnalyticsClient_System);
}
}
static void prv_click_config_provider(void *context) {
window_single_click_subscribe(BUTTON_ID_BACK, prv_back_click_handler);
window_single_click_subscribe(BUTTON_ID_UP, prv_up_down_click_handler);
window_single_click_subscribe(BUTTON_ID_DOWN, prv_up_down_click_handler);
window_single_click_subscribe(BUTTON_ID_SELECT, prv_select_click_handler);
}
static void prv_blobdb_event_handler(PebbleEvent *event, void *context) {
TimelineAppData *data = context;
PebbleBlobDBEvent *blobdb_event = &event->blob_db;
if (blobdb_event->db_id != BlobDBIdPins) {
// we only care about pins
return;
}
BlobDBEventType type = blobdb_event->type;
Uuid *id = (Uuid *)blobdb_event->key;
if (type == BlobDBEventTypeDelete) {
if (prv_pin_in_card(data, id)) {
// remove the pin window if we just removed the pin
app_window_stack_remove((Window *)&data->pin_window, false);
prv_set_state(data, TimelineAppStateStationary);
}
timeline_model_remove(id);
prv_update_timeline_layer(data);
} else if (type == BlobDBEventTypeInsert) {
for (int i = 0; i < TIMELINE_NUM_VISIBLE_ITEMS; i++) {
if (timeline_model_get_iter_state(i)->node &&
uuid_equal(&timeline_model_get_iter_state(i)->pin.header.id, id)) {
prv_refresh_pin(data, i);
}
}
prv_update_timeline_layer(data);
}
}
/////////////////////////////////////
// Intro Animation
/////////////////////////////////////
static void prv_intro_anim_stopped(Animation *anim, bool finished, void *context) {
TimelineAppData *data = context;
i18n_free_all(&data->peek_layer);
peek_layer_deinit(&data->peek_layer);
window_set_click_config_provider_with_context(&data->timeline_window, prv_click_config_provider,
data);
data->timeline_layer.animating_intro_or_exit = false;
if (!finished || (!prv_set_state(s_app_data, TimelineAppStateStationary) &&
!prv_set_state(s_app_data, TimelineAppStateDaySeparator))) {
return;
}
data->current_animation = NULL;
prv_update_timeline_layer(data);
if (data->state == TimelineAppStateDaySeparator) {
// Hidden until the day separator hide animation stops
timeline_layer_set_layouts_hidden(&data->timeline_layer, true);
#if ANIMATION_DOT
timeline_layer_unfold_day_sep(&data->timeline_layer);
#elif ANIMATION_SLIDE
timeline_layer_slide_day_sep(&data->timeline_layer);
#endif
prv_set_day_sep_timer(data);
} else {
#if CAPABILITY_HAS_TIMELINE_PEEK
GPoint direction = GPoint(0, -1);
#else
GPoint direction = GPoint(1, 0);
#endif
Animation *layer_bounce = timeline_layer_create_bounce_back_animation(&data->timeline_layer,
direction);
data->current_animation = layer_bounce;
animation_schedule(layer_bounce);
}
}
static Animation *prv_create_intro_animation(TimelineAppData *data, uint32_t duration,
bool was_mini_peek) {
// Animation structure:
// - Scheduled simultaneously
// - Spawn
// - Move peek layer to right (frame)
// - Resize sidebar from fullscreen to thin
// - After completion
// - Bounce back timeline pin layouts
// - Speed lines (if launching into a deep pin)
// animate the peek layer to the right
GRect *start = &data->peek_layer.layer.frame;
GRect stop = { { was_mini_peek ? 0 : start->size.w , 0 }, start->size };
Animation *peek_out = (Animation *)property_animation_create_layer_frame(
(Layer *)&data->peek_layer, start, &stop);
animation_set_duration(peek_out, duration);
animation_set_custom_interpolation(peek_out, interpolate_moook_in_only);
// resize the sidebar from fullscreen to become thin on the right
Animation *sidebar_slide = prv_create_sidebar_animation(data, true /* open */);
animation_set_duration(sidebar_slide, duration);
Animation *speed_lines = data->launch_into_deep_pin ?
timeline_layer_create_speed_lines_animation(&data->timeline_layer) : NULL;
return animation_spawn_create(peek_out, sidebar_slide, speed_lines, NULL);
}
static void prv_play_peek_in(TimelineAppData *data) {
// Skip the first frame since the icon is offscreen
const int num_frames_skip = 1;
// The peek layer scale animation has a bounce back effect, so the icon reaches the destination
// if set to exactly the short moook in duration, so extend with more frames
const int num_frames_extend = 3;
const uint32_t duration = interpolate_moook_in_duration();
peek_layer_set_duration(&data->peek_layer, duration + ((num_frames_skip + num_frames_extend) *
ANIMATION_TARGET_FRAME_INTERVAL_MS));
Animation *animation = (Animation *)peek_layer_create_play_animation(&data->peek_layer);
animation_schedule(animation);
animation_set_elapsed(animation, num_frames_skip * ANIMATION_TARGET_FRAME_INTERVAL_MS);
}
static void prv_scale_peek_to_first_pin_icon(TimelineAppData *data, uint32_t duration,
bool was_mini_peek) {
TimelineLayout *first_timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (!first_timeline_layout) {
return;
}
// scale the peek layer icon to the pin position
GRect frame;
layer_get_global_frame((Layer *)first_timeline_layout, &frame);
GRect icon_to;
timeline_layout_get_icon_frame(&frame, data->timeline_layer.scroll_direction, &icon_to);
const bool align_in_frame = true;
PeekLayer *peek_layer = &data->peek_layer;
peek_layer_set_scale_to_image(peek_layer, &first_timeline_layout->icon_info,
TimelineResourceSizeTiny, icon_to, align_in_frame);
#if ANIMATION_SLIDE
if (was_mini_peek) {
prv_play_peek_in(data);
return;
}
#endif
peek_layer_set_duration(peek_layer, duration);
peek_layer_play(peek_layer);
}
static void prv_intro_timer_callback(void *context) {
TimelineAppData *data = context;
data->intro_timer_id = EVENTED_TIMER_INVALID_ID;
// if we are already hiding the peek, we were in a mini peek
const bool was_mini_peek = (data->state == TimelineAppStateHidePeek);
prv_set_state(data, TimelineAppStateHidePeek);
if (data->state != TimelineAppStateHidePeek &&
data->state != TimelineAppStateFarDayHidePeek) {
return;
}
// hide the peek text
PeekLayer *peek_layer = &data->peek_layer;
peek_layer_clear_fields(peek_layer);
animation_unschedule(data->current_animation);
const uint32_t duration = was_mini_peek ? interpolate_moook_in_duration() :
TIMELINE_SLIDE_ANIMATION_MS;
Animation *intro = prv_create_intro_animation(data, duration, was_mini_peek);
animation_set_handlers(intro, (AnimationHandlers) {
.started = prv_intro_or_exit_anim_started,
.stopped = prv_intro_anim_stopped,
}, data);
data->current_animation = intro;
animation_schedule(intro);
if (!layer_get_hidden((Layer *)&data->peek_layer)) {
prv_scale_peek_to_first_pin_icon(data, duration, was_mini_peek);
}
}
static void prv_open_did_focus_handler(PebbleEvent *e, void *context) {
TimelineAppData *data = context;
event_service_client_unsubscribe(&data->focus_event_info);
prv_intro_timer_callback(data);
}
static void prv_peek_did_focus_handler(PebbleEvent *e, void *context) {
TimelineAppData *data = context;
event_service_client_unsubscribe(&data->focus_event_info);
#if ANIMATION_DOT
peek_layer_play(&data->peek_layer);
#elif ANIMATION_SLIDE
prv_play_peek_in(data);
#endif
if (data->state == TimelineAppStateNoEvents) {
window_set_click_config_provider_with_context(&data->timeline_window, prv_click_config_provider,
data);
} else if (data->state == TimelineAppStatePeek &&
data->intro_timer_id == EVENTED_TIMER_INVALID_ID) {
data->intro_timer_id = evented_timer_register(PEEK_SHOW_TIME_MS,
false,
prv_intro_timer_callback,
data);
}
}
static void prv_setup_peek_animation(TimelineAppData *data, TimelineResourceInfo *timeline_res,
bool use_pin) {
PeekLayer *peek_layer = &data->peek_layer;
#if ANIMATION_DOT
peek_layer_set_icon(peek_layer, timeline_res);
#elif ANIMATION_SLIDE
GRect icon_from;
GRect icon_to;
const bool shift_offscreen_from = true;
const bool shift_offscreen_to = false;
prv_get_icon_animation_frame(data, &icon_from, use_pin, shift_offscreen_from);
prv_get_icon_animation_frame(data, &icon_to, use_pin, shift_offscreen_to);
peek_layer_set_icon_with_size(peek_layer, timeline_res, TimelineResourceSizeLarge, icon_from);
peek_layer_set_scale_to(peek_layer, icon_to);
peek_layer_set_fields_hidden(peek_layer, true);
#endif
}
T_STATIC void prv_setup_no_events_peek(TimelineAppData *data) {
PeekLayer *peek_layer = &data->peek_layer;
// set the text
peek_layer_set_fields(peek_layer, "", i18n_get("No events", peek_layer), "");
// set the icon resource
TimelineResourceInfo timeline_res = {
.res_id = TIMELINE_RESOURCE_NO_EVENTS,
};
const bool use_pin = false;
prv_setup_peek_animation(data, &timeline_res, use_pin);
}
static void prv_setup_first_pin_peek(TimelineAppData *data) {
TimelineIterState *state = timeline_model_get_current_state();
// TODO: PBL-22075 Refactor Timeline Model
// timeline_model_get_current_state explicitly tries to return NULL when supposedly empty,
// but this does not seem to actually happen
if (!state) {
return;
}
TimelineItem *first_pin = &state->pin;
if (!first_pin) {
return;
}
TimelineLayout *first_timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (!first_timeline_layout) {
return;
}
PeekLayer *peek_layer = &s_app_data->peek_layer;
#if PLATFORM_TINTIN
const bool is_mini_peek = false;
#else
// if we are hiding the peek, we are in a mini peek
const bool is_mini_peek = (data->state == TimelineAppStateHidePeek);
#endif
// set the text
char number_buffer[TIME_STRING_REQUIRED_LENGTH] = {}; // "11"
char word_buffer[TIME_STRING_REQUIRED_LENGTH] = {}; // "min to"
if (!is_mini_peek) {
clock_get_event_relative_time_string(
number_buffer, sizeof(number_buffer), word_buffer, sizeof(word_buffer),
first_pin->header.timestamp, first_pin->header.duration, state->current_day,
first_pin->header.all_day);
}
peek_layer_set_fields(peek_layer, number_buffer, word_buffer, "");
// set the icon
if (is_mini_peek) {
GRect icon_from;
const bool shift_offscreen = true;
const bool use_pin = true;
prv_get_icon_animation_frame(data, &icon_from, use_pin, shift_offscreen);
peek_layer_set_icon_with_size(peek_layer, &first_timeline_layout->icon_info,
TimelineResourceSizeTiny, icon_from);
} else {
const bool use_pin = false;
prv_setup_peek_animation(data, &first_timeline_layout->icon_info, use_pin);
}
}
static void NOINLINE prv_setup_peek(TimelineAppData *data) {
TimelineIterState *state = timeline_model_get_current_state();
TimelineItem *first_pin = state ? &state->pin : NULL;
EventServiceEventHandler focus_handler = prv_open_did_focus_handler;
// we'll only show the first pin peek if timeline peek (aka quick view) isn't enabled
time_t now = rtc_get_time();
if (!first_pin && prv_set_state(s_app_data, TimelineAppStateNoEvents)) {
layer_set_hidden((Layer *)&data->peek_layer, false);
prv_setup_no_events_peek(data);
focus_handler = prv_peek_did_focus_handler;
#if !CAPABILITY_HAS_TIMELINE_PEEK
} else if (first_pin && (first_pin->header.timestamp + SECONDS_PER_MINUTE *
first_pin->header.duration >= now) &&
(first_pin->header.timestamp - SECONDS_PER_HOUR <= now) &&
prv_set_state(data, TimelineAppStatePeek)) {
// ongoing or within the hour
prv_setup_first_pin_peek(data);
focus_handler = prv_peek_did_focus_handler;
#endif
} else if (state && (state->current_day != time_util_get_midnight_of(now)) &&
prv_set_state(data, TimelineAppStateFarDayHidePeek)) {
// entering into a day that isn't today, setup the day separator
layer_set_hidden((Layer *)&data->peek_layer, true);
#if ANIMATION_DOT
timeline_layer_set_day_sep_frame(&data->timeline_layer, &data->timeline_layer.layer.frame);
#elif ANIMATION_SLIDE
GRect frame;
layer_get_frame(&data->timeline_layer.day_separator.layer, &frame);
const bool is_future = (s_app_data->timeline_model.direction == TimelineIterDirectionFuture);
frame.origin.y += is_future ? DISP_ROWS : -DISP_ROWS;
timeline_layer_set_day_sep_frame(&data->timeline_layer, &frame);
#endif
focus_handler = prv_open_did_focus_handler;
} else if (prv_set_state(data, TimelineAppStateHidePeek)) {
// setup mini-peek where the icon animates directly into the pin position
prv_setup_first_pin_peek(data);
focus_handler = prv_open_did_focus_handler;
}
// set the did_focus handler
data->focus_event_info = (EventServiceInfo) {
.type = PEBBLE_APP_DID_CHANGE_FOCUS_EVENT,
.handler = focus_handler,
.context = s_app_data,
};
event_service_client_subscribe(&s_app_data->focus_event_info);
}
static GColor prv_get_sidebar_color(TimelineAppData *data) {
if (s_app_data->timeline_model.direction == TimelineIterDirectionPast) {
return TIMELINE_PAST_COLOR;
} else {
return TIMELINE_FUTURE_COLOR;
}
}
T_STATIC void prv_init_peek_layer(TimelineAppData *data) {
Window *window = &data->timeline_window;
PeekLayer *peek_layer = &data->peek_layer;
const TimelineAppStyle *style = prv_get_style();
const GRect frame = { .origin.y = style->peek_offset_y, .size = window->layer.bounds.size };
peek_layer_init(peek_layer, &frame);
peek_layer_set_icon_offset_y(peek_layer, style->peek_icon_offset_y);
peek_layer_set_frame(peek_layer, &frame);
peek_layer_set_background_color(peek_layer, GColorClear);
layer_add_child(&window->layer, &peek_layer->layer);
}
static void prv_timeline_window_load(Window *window) {
TimelineLayer *timeline_layer = &s_app_data->timeline_layer;
TimelineScrollDirection scroll_direction;
if (s_app_data->timeline_model.direction == TimelineIterDirectionPast) {
scroll_direction = TimelineScrollDirectionUp;
} else {
scroll_direction = TimelineScrollDirectionDown;
}
window_set_background_color(window, GColorWhite);
// timeline layer
timeline_layer_init(timeline_layer, &window->layer.bounds, scroll_direction);
timeline_layer_set_sidebar_color(timeline_layer,
PBL_IF_COLOR_ELSE(prv_get_sidebar_color(s_app_data),
GColorLightGray));
timeline_layer_set_layouts_hidden(timeline_layer, true); // hide until the peek is over
layer_set_hidden((Layer *)&timeline_layer->day_separator, true);
layer_add_child(&window->layer, (Layer *)timeline_layer);
// peek layer
prv_init_peek_layer(s_app_data);
prv_setup_peek(s_app_data);
}
static void prv_timeline_window_appear(Window *window) {
TimelineAppData *data = window_get_user_data(window);
// re-enable the inactivity timer back in timeline view
prv_inactive_timer_refresh(data);
}
static void prv_timeline_window_disappear(Window *window) {
TimelineAppData *data = window_get_user_data(window);
// disable the inactivity timer when the user leaves
prv_cleanup_timer(&data->inactive_timer_id);
}
static void prv_timeline_window_unload(Window *window) {
TimelineAppData *data = window_get_user_data(window);
// clean up any running animations
animation_unschedule(data->current_animation);
prv_cleanup_timer(&data->day_separator_timer_id);
}
static void prv_back_from_card_stopped(Animation *animation, bool finished, void *context) {
TimelineAppData *data = context;
if (!finished || !prv_set_state(data, TimelineAppStateStationary)) {
return;
}
window_set_background_color(&data->timeline_window, GColorWhite);
data->current_animation = NULL;
prv_update_timeline_layer(data);
Animation *layer_bounce = timeline_layer_create_bounce_back_animation(&data->timeline_layer,
GPoint(1, 0));
data->current_animation = layer_bounce;
animation_schedule(layer_bounce);
}
/////////////////////////////////////
// Public API
/////////////////////////////////////
Animation *timeline_animate_back_from_card(void) {
TimelineAppData *data = s_app_data;
PBL_ASSERTN(data);
if (!prv_set_state(data, TimelineAppStatePopCard)) {
return NULL;
}
// Animation structure:
// - Scheduled simultaneously
// - Transition card to pin
// - Move timeline layer from the left
// - After completion
// - Bounce back the timeline layer
// - Move pin window to the right
animation_unschedule(data->current_animation);
timeline_layer_set_layouts_hidden(&data->timeline_layer, true);
window_set_background_color(&data->timeline_window, GColorWhite);
#if !PLATFORM_TINTIN
TimelineLayout *pin_timeline_layout = timeline_layer_get_current_layout(&data->timeline_layer);
if (pin_timeline_layout) {
// animation the pin icon
TimelineItemLayer *item_layer = &data->pin_window.item_detail_layer;
timeline_layout_transition_card_to_pin(item_layer->timeline_layout, pin_timeline_layout);
}
#endif
// animate the timeline layer from the left
Layer *layer = &data->timeline_layer.layer;
GPoint from_origin = { -layer->bounds.size.w, 0 };
Animation *layer_in = (Animation *)property_animation_create_bounds_origin(layer, &from_origin,
&GPointZero);
animation_set_duration(layer_in, TIMELINE_CARD_TRANSITION_MS / 2);
animation_set_custom_interpolation(layer_in, interpolate_moook);
animation_set_handlers(layer_in, (AnimationHandlers) {
.stopped = prv_back_from_card_stopped,
}, data);
data->current_animation = layer_in;
animation_schedule(layer_in);
// animate the card layout
timeline_pin_window_pop(&data->pin_window);
return layer_in;
}
/////////////////////////////////////
// App boilerplate
/////////////////////////////////////
static bool NOINLINE prv_setup_timeline_app(void) {
TimelineAppData *data = app_malloc_check(sizeof(TimelineAppData));
s_app_data = data;
*data = (TimelineAppData){};
data->blobdb_event_info = (EventServiceInfo) {
.type = PEBBLE_BLOBDB_EVENT,
.handler = prv_blobdb_event_handler,
.context = data,
};
event_service_client_subscribe(&data->blobdb_event_info);
const TimelineArgs *args = process_manager_get_current_process_args();
Uuid app_uuid;
sys_get_app_uuid(&app_uuid);
if (uuid_equal(&app_uuid, &(Uuid)TIMELINE_PAST_UUID_INIT)) {
data->timeline_model.direction = TimelineIterDirectionPast;
} else if (args == NULL) {
data->timeline_model.direction = TimelineIterDirectionFuture;
} else {
data->timeline_model.direction = args->direction;
}
// check if we were asked to launch into a specific item
time_t now = rtc_get_time();
TimelineItem pin;
bool launch_into_pin = false;
if (args && args->launch_into_pin &&
!uuid_is_invalid(&args->pin_id) &&
pin_db_get(&args->pin_id, &pin) == S_SUCCESS) {
launch_into_pin = true;
if (!args->stay_in_list_view) {
// Launching directly into the pin, change the direction to match
data->timeline_model.direction =
timeline_direction_for_item(&pin, data->timeline_model.timeline, now);
}
timeline_item_free_allocated_buffer(&pin);
}
timeline_model_init(now, &data->timeline_model);
// if we're launching into a particular item, we iterate to it now
if (launch_into_pin) {
while (!uuid_equal(&timeline_model_get_current_state()->pin.header.id, &args->pin_id)) {
data->launch_into_deep_pin = true;
if (!timeline_model_iter_next(NULL, NULL)) {
// for some reason we can't find the pin we were asked to launch into
char uuid_buffer[UUID_STRING_BUFFER_LENGTH];
uuid_to_string(&args->pin_id, uuid_buffer);
PBL_LOG(LOG_LEVEL_ERROR, "Asked to launch into pin but can't find it %s", uuid_buffer);
launch_into_pin = false;
data->launch_into_deep_pin = false;
// we couldn't find the launch pin, go back to the present
while (timeline_model_iter_prev(NULL, NULL)) {}
break;
}
}
}
Window *window = &data->timeline_window;
window_init(window, WINDOW_NAME("Timeline"));
window_set_user_data(window, data);
window_set_window_handlers(window, &(WindowHandlers) {
.load = prv_timeline_window_load,
.appear = prv_timeline_window_appear,
.disappear = prv_timeline_window_disappear,
.unload = prv_timeline_window_unload
});
return (launch_into_pin && !(args && args->stay_in_list_view));
}
T_STATIC void NOINLINE prv_init(void) {
bool do_push_pin_window = prv_setup_timeline_app();
app_window_stack_push(&s_app_data->timeline_window, true /* animated */);
if (do_push_pin_window) {
prv_push_pin_window(s_app_data, timeline_model_get_current_state(), false /* animated */);
}
#if CAPABILITY_HAS_TIMELINE_PEEK
if (!timeline_model_is_empty()) {
timeline_layer_set_sidebar_width(&s_app_data->timeline_layer,
timeline_layer_get_ideal_sidebar_width());
}
#endif
}
static void NOINLINE prv_deinit(void) {
prv_cleanup_timer(&s_app_data->intro_timer_id);
prv_cleanup_timer(&s_app_data->inactive_timer_id);
event_service_client_unsubscribe(&s_app_data->focus_event_info);
event_service_client_unsubscribe(&s_app_data->blobdb_event_info);
timeline_layer_deinit(&s_app_data->timeline_layer);
timeline_model_deinit();
app_free(s_app_data);
}
static void prv_main(void) {
prv_init();
app_event_loop();
prv_deinit();
}
const PebbleProcessMd *timeline_get_app_info() {
static const PebbleProcessMdSystem s_app_md = {
.common = {
.main_func = prv_main,
// uuid: 79C76B48-6111-4E80-8DEB-3119EEBEF33E
.uuid = {0x79, 0xC7, 0x6B, 0x48, 0x61, 0x11, 0x4E, 0x80,
0x8D, 0xEB, 0x31, 0x19, 0xEE, 0xBE, 0xF3, 0x3E},
.visibility = ProcessVisibilityHidden,
},
.name = "Timeline",
};
return &s_app_md.common;
}
const PebbleProcessMd *timeline_past_get_app_info() {
static const PebbleProcessMdSystem s_app_md = {
.common = {
.main_func = prv_main,
.uuid = TIMELINE_PAST_UUID_INIT,
.visibility = ProcessVisibilityQuickLaunch,
},
/// The title of Timeline Past in Quick Launch. If the translation is too long, cut out
/// Timeline and only translate "Past".
.name = i18n_noop("Timeline Past"),
};
return &s_app_md.common;
}