Skip to content

Commit 8894889

Browse files
committed
Add a timer widget for use when asking about timers.
Signed-off-by: Katharine Berry <ktbry@google.com>
1 parent f669d8e commit 8894889

File tree

17 files changed

+322
-10
lines changed

17 files changed

+322
-10
lines changed

app/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,5 @@ add_executable(tiny_assistant_app
6666
src/c/converse/segments/widgets/weather_multi_day.c
6767
src/c/talking_horse_layer.c
6868
src/c/version/version.c
69-
src/c/util/time.c)
69+
src/c/util/time.c
70+
src/c/converse/segments/widgets/timer.c)

app/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@
7676
"REMINDER_DELETE",
7777
"ACTION_REMINDER_DELETED",
7878
"SET_ALARM_NAME",
79-
"GET_ALARM_NAME[9]"
79+
"GET_ALARM_NAME[9]",
80+
"TIMER_WIDGET",
81+
"TIMER_WIDGET_TARGET_TIME",
82+
"TIMER_WIDGET_NAME"
8083
],
8184
"resources": {
8285
"media": [

app/src/c/converse/conversation.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ void conversation_destroy(Conversation* conversation) {
102102
case ConversationWidgetTypeWeatherMultiDay:
103103
free(entry->content.widget->widget.weather_multi_day.location);
104104
break;
105+
case ConversationWidgetTypeTimer:
106+
if (entry->content.widget->widget.timer.name) {
107+
free(entry->content.widget->widget.timer.name);
108+
}
109+
break;
105110
}
106111
free(entry->content.widget);
107112
break;

app/src/c/converse/conversation.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ typedef enum {
3434
ConversationWidgetTypeWeatherSingleDay,
3535
ConversationWidgetTypeWeatherCurrent,
3636
ConversationWidgetTypeWeatherMultiDay,
37+
ConversationWidgetTypeTimer,
3738
} ConversationWidgetType;
3839

3940
typedef struct {
@@ -118,12 +119,18 @@ typedef struct {
118119
ConversationWidgetWeatherMultiDaySegment days[3];
119120
} ConversationWidgetWeatherMultiDay;
120121

122+
typedef struct {
123+
time_t target_time;
124+
char *name;
125+
} ConversationWidgetTimer;
126+
121127
typedef struct {
122128
ConversationWidgetType type;
123129
union {
124130
ConversationWidgetWeatherSingleDay weather_single_day;
125131
ConversationWidgetWeatherCurrent weather_current;
126132
ConversationWidgetWeatherMultiDay weather_multi_day;
133+
ConversationWidgetTimer timer;
127134
} widget;
128135
} ConversationWidget;
129136

app/src/c/converse/conversation_manager.c

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ static void prv_handle_app_message_outbox_failed(DictionaryIterator *iterator, A
3434
static void prv_handle_app_message_inbox_received(DictionaryIterator *iterator, void *context);
3535
static void prv_handle_app_message_inbox_dropped(AppMessageResult result, void *context);
3636
static void prv_process_weather_widget(int widget_type, DictionaryIterator *iter, ConversationManager *manager);
37+
static void prv_process_timer_widget(int widget_type, DictionaryIterator *iter, ConversationManager *manager);
3738

3839
static ConversationManager* s_conversation_manager;
3940

@@ -174,7 +175,13 @@ static void prv_handle_app_message_inbox_received(DictionaryIterator *iter, void
174175
conversation_add_error(manager->conversation, tuple->value->cstring);
175176
prv_conversation_updated(manager, true);
176177
} else if (tuple->key == MESSAGE_KEY_WEATHER_WIDGET) {
178+
conversation_complete_response(manager->conversation);
179+
prv_conversation_updated(manager, false);
177180
prv_process_weather_widget(tuple->value->int32, iter, manager);
181+
} else if (tuple->key == MESSAGE_KEY_TIMER_WIDGET) {
182+
conversation_complete_response(manager->conversation);
183+
prv_conversation_updated(manager, false);
184+
prv_process_timer_widget(tuple->value->int32, iter, manager);
178185
}
179186
}
180187
}
@@ -275,6 +282,28 @@ static void prv_process_weather_widget(int widget_type, DictionaryIterator *iter
275282
}
276283
}
277284

285+
static void prv_process_timer_widget(int widget_type, DictionaryIterator *iter, ConversationManager *manager) {
286+
time_t target_time = dict_find(iter, MESSAGE_KEY_TIMER_WIDGET_TARGET_TIME)->value->int32;
287+
char *name_stored = NULL;
288+
if (dict_find(iter, MESSAGE_KEY_TIMER_WIDGET_NAME)) {
289+
const char *name = NULL;
290+
name = dict_find(iter, MESSAGE_KEY_TIMER_WIDGET_NAME)->value->cstring;
291+
name_stored = malloc(strlen(name) + 1);
292+
strcpy(name_stored, name);
293+
}
294+
ConversationWidget widget = {
295+
.type = ConversationWidgetTypeTimer,
296+
.widget = {
297+
.timer = {
298+
.target_time = target_time,
299+
.name = name_stored,
300+
}
301+
}
302+
};
303+
conversation_add_widget(manager->conversation, &widget);
304+
prv_conversation_updated(manager, true);
305+
}
306+
278307
static void prv_handle_app_message_inbox_dropped(AppMessageResult reason, void *context) {
279308
APP_LOG(APP_LOG_LEVEL_ERROR, "Received message dropped: %d", reason);
280309
ConversationManager* manager = context;

app/src/c/converse/segments/segment_layer.c

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
#include <pebble.h>
2626

27+
#include "widgets/timer.h"
28+
2729
#define CONTENT_FONT FONT_KEY_GOTHIC_24_BOLD
2830
#define NAME_HEIGHT 15
2931

@@ -33,6 +35,7 @@ typedef enum {
3335
SegmentTypeWeatherSingleDayWidget,
3436
SegmentTypeWeatherCurrentWidget,
3537
SegmentTypeWeatherMultiDayWidget,
38+
SegmentTypeTimerWidget,
3639
} SegmentType;
3740

3841
typedef struct {
@@ -48,6 +51,7 @@ typedef struct {
4851
WeatherSingleDayWidget* weather_single_day_widget;
4952
WeatherCurrentWidget* weather_current_widget;
5053
WeatherMultiDayWidget* weather_multi_day_widget;
54+
TimerWidget* timer_widget;
5155
};
5256
} SegmentLayerData;
5357

@@ -80,6 +84,9 @@ SegmentLayer* segment_layer_create(GRect rect, ConversationEntry* entry) {
8084
data->weather_multi_day_widget = weather_multi_day_widget_create(child_frame, entry);
8185
layer_add_child(layer, data->weather_multi_day_widget);
8286
break;
87+
case SegmentTypeTimerWidget:
88+
data->timer_widget = timer_widget_create(child_frame, entry);
89+
layer_add_child(layer, data->timer_widget);
8390
}
8491
GSize child_size = layer_get_frame(data->layer).size;
8592
layer_set_frame(layer, GRect(rect.origin.x, rect.origin.y, child_size.w, child_size.h));
@@ -104,6 +111,9 @@ void segment_layer_destroy(SegmentLayer* layer) {
104111
case SegmentTypeWeatherMultiDayWidget:
105112
weather_multi_day_widget_destroy(data->weather_multi_day_widget);
106113
break;
114+
case SegmentTypeTimerWidget:
115+
timer_widget_destroy(data->timer_widget);
116+
break;
107117
}
108118
layer_destroy(layer);
109119
}
@@ -131,6 +141,9 @@ void segment_layer_update(SegmentLayer* layer) {
131141
case SegmentTypeWeatherMultiDayWidget:
132142
weather_multi_day_widget_update(data->weather_multi_day_widget);
133143
break;
144+
case SegmentTypeTimerWidget:
145+
timer_widget_update(data->timer_widget);
146+
break;
134147
}
135148
GSize child_size = layer_get_frame(data->layer).size;
136149
GPoint origin = layer_get_frame(layer).origin;
@@ -154,6 +167,8 @@ static SegmentType prv_get_segment_type(ConversationEntry* entry) {
154167
return SegmentTypeWeatherCurrentWidget;
155168
case ConversationWidgetTypeWeatherMultiDay:
156169
return SegmentTypeWeatherMultiDayWidget;
170+
case ConversationWidgetTypeTimer:
171+
return SegmentTypeTimerWidget;
157172
}
158173
break;
159174
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include "timer.h"
18+
#include "../../conversation.h"
19+
#include "../../../util/style.h"
20+
#include <pebble.h>
21+
#include <pebble-events/pebble-events.h>
22+
23+
typedef struct {
24+
ConversationEntry* entry;
25+
GDrawCommandImage *icon;
26+
EventHandle event_handle;
27+
char text[12];
28+
} TimerWidgetData;
29+
30+
static void prv_layer_update(Layer *layer, GContext *ctx);
31+
static void prv_handle_tick(struct tm *tick_time, TimeUnits units_changed, void *context);
32+
static void prv_update_text_buffer(TimerWidgetData* data);
33+
34+
TimerWidget* timer_widget_create(GRect rect, ConversationEntry* entry) {
35+
Layer *layer = layer_create_with_data(GRect(rect.origin.x, rect.origin.y, rect.size.w, 53), sizeof(TimerWidgetData));
36+
TimerWidgetData* data = layer_get_data(layer);
37+
38+
data->entry = entry;
39+
data->icon = gdraw_command_image_create_with_resource(RESOURCE_ID_TIMER_ICON);
40+
prv_update_text_buffer(data);
41+
layer_set_update_proc(layer, prv_layer_update);
42+
43+
data->event_handle = events_tick_timer_service_subscribe_context(SECOND_UNIT, prv_handle_tick, layer);
44+
return layer;
45+
}
46+
47+
ConversationEntry* timer_widget_get_entry(TimerWidget* layer) {
48+
TimerWidgetData* data = layer_get_data(layer);
49+
return data->entry;
50+
}
51+
52+
void timer_widget_destroy(TimerWidget* layer) {
53+
TimerWidgetData* data = layer_get_data(layer);
54+
gdraw_command_image_destroy(data->icon);
55+
events_tick_timer_service_unsubscribe(data->event_handle);
56+
layer_destroy(layer);
57+
}
58+
59+
void timer_widget_update(TimerWidget* layer) {
60+
// nothing to do here.
61+
}
62+
63+
static void prv_layer_update(Layer *layer, GContext *ctx) {
64+
TimerWidgetData* data = layer_get_data(layer);
65+
ConversationWidgetTimer *widget = &conversation_entry_get_widget(data->entry)->widget.timer;
66+
GRect bounds = layer_get_bounds(layer);
67+
#if defined(PBL_COLOR)
68+
graphics_context_set_fill_color(ctx, BRANDED_BACKGROUND_COLOUR);
69+
graphics_context_set_text_color(ctx, gcolor_legible_over(BRANDED_BACKGROUND_COLOUR));
70+
graphics_fill_rect(ctx, bounds, 0, GCornerNone);
71+
#else
72+
graphics_context_set_text_color(ctx, GColorBlack);
73+
#endif
74+
graphics_context_set_stroke_color(ctx, GColorBlack);
75+
graphics_draw_line(ctx, GPoint(0, 0), GPoint(bounds.size.w, 0));
76+
graphics_draw_line(ctx, GPoint(0, bounds.size.h - 1), GPoint(bounds.size.w, bounds.size.h - 1));
77+
78+
gdraw_command_image_draw(ctx, data->icon, GPoint(5, 3));
79+
80+
const int16_t icon_space = 26;
81+
const GRect title_rect = GRect(icon_space, bounds.origin.y, bounds.size.w - icon_space, 20);
82+
const GRect time_rect = GRect(5, bounds.origin.y + 16, bounds.size.w - 5, bounds.size.h);
83+
84+
GFont time_font = fonts_get_system_font(FONT_KEY_LECO_32_BOLD_NUMBERS);
85+
GFont title_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD);
86+
graphics_draw_text(ctx, widget->name ? widget->name : "Timer", title_font, title_rect, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL);
87+
graphics_draw_text(ctx, data->text, time_font, time_rect, GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, NULL);
88+
}
89+
90+
static void prv_handle_tick(struct tm *tick_time, TimeUnits units_changed, void *context) {
91+
TimerWidget* layer = context;
92+
TimerWidgetData* data = layer_get_data(layer);
93+
prv_update_text_buffer(data);
94+
layer_mark_dirty(context);
95+
}
96+
97+
static void prv_update_text_buffer(TimerWidgetData* data) {
98+
time_t now = time(NULL);
99+
ConversationWidgetTimer *widget = &conversation_entry_get_widget(data->entry)->widget.timer;
100+
101+
if (widget->target_time <= now) {
102+
strncpy(data->text, "0:00", sizeof(data->text));
103+
return;
104+
}
105+
time_t interval = widget->target_time - now;
106+
int hours = interval / 3600;
107+
int minutes = (interval % 3600) / 60;
108+
int seconds = interval % 60;
109+
if (hours >= 10) {
110+
snprintf(data->text, sizeof(data->text), "%d:%02d", hours, minutes);
111+
} else if (hours > 0) {
112+
snprintf(data->text, sizeof(data->text), "%d:%02d:%02d", hours, minutes, seconds);
113+
} else {
114+
snprintf(data->text, sizeof(data->text), "%d:%02d", minutes, seconds);
115+
}
116+
data->text[sizeof(data->text) - 1] = '\0';
117+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#pragma once
18+
19+
#include <pebble.h>
20+
#include "../../conversation.h"
21+
22+
typedef Layer TimerWidget;
23+
24+
TimerWidget* timer_widget_create(GRect rect, ConversationEntry* entry);
25+
ConversationEntry* timer_widget_get_entry(TimerWidget* layer);
26+
void timer_widget_destroy(TimerWidget* layer);
27+
void timer_widget_update(TimerWidget* layer);

app/src/pkjs/actions/alarms.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function getAlarm(session, message, callback) {
164164
formattedTimeLeft = minutes + ":" + leftPad2(seconds);
165165
}
166166
var expirationDate = new Date(alarmTime * 1000);
167-
var r = {"secondsLeft": secondsLeft, "formattedTimeLeft": formattedTimeLeft, "expirationTimeForDeleting": expirationDate.toISOString()};
167+
var r = {"secondsLeft": secondsLeft, "formattedTimeLeft": formattedTimeLeft, "expirationTimeForDeletingAndWidgets": expirationDate.toISOString()};
168168
if (alarmName) {
169169
r.name = alarmName;
170170
}

app/src/pkjs/session.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Session.prototype.run = function() {
5151
// negate this because JavaScript does it backwards for some reason.
5252
url += '&tzOffset=' + (-(new Date()).getTimezoneOffset());
5353
url += '&actions=' + actions.getSupportedActions().join(',');
54-
url += '&widgets=weather';
54+
url += '&widgets=weather,timer';
5555
var settings = getSettings();
5656
url += '&units=' + settings['UNIT_PREFERENCE'] || '';
5757
url += '&lang=' + settings['LANGUAGE_CODE'] || '';
@@ -68,14 +68,25 @@ Session.prototype.handleMessage = function(event) {
6868
var message = event.data;
6969
console.log(message);
7070
if (message[0] == 'c') {
71-
var widgetRegex = /<<!!WIDGET:(.+?)!!>>/g;
71+
var widgetRegex = /<<!!WIDGET:(.+?)!!>>/;
7272
var content = message.substring(1);
7373
var match;
74-
while (match = widgetRegex.exec(content)) {
74+
while (content.length > 0) {
75+
match = widgetRegex.exec(content);
76+
if (!match) {
77+
break;
78+
}
7579
var widget = match[1];
7680
console.log("Widget found: " + widget);
77-
content = content.replace(match[0], '');
81+
var start = match.index;
82+
if (start != 0) {
83+
this.enqueue({
84+
CHAT: content.substring(0, start)
85+
});
86+
}
7887
this.processWidget(widget);
88+
this.hasOpenDialog = false;
89+
content = content.substring(match.index + match[0].length);
7990
}
8091
if (content.length > 0) {
8192
this.hasOpenDialog = true;

0 commit comments

Comments
 (0)