diff --git a/movement_config.h b/movement_config.h index 2f48d8fd..21001029 100644 --- a/movement_config.h +++ b/movement_config.h @@ -33,6 +33,7 @@ const watch_face_t watch_faces[] = { sunrise_sunset_face, moon_phase_face, fast_stopwatch_face, + billing_face, countdown_face, alarm_face, temperature_display_face, diff --git a/movement_faces.h b/movement_faces.h index cc8cf413..f09d7b51 100644 --- a/movement_faces.h +++ b/movement_faces.h @@ -32,6 +32,7 @@ #include "countdown_face.h" #include "stopwatch_face.h" #include "fast_stopwatch_face.h" +#include "billing_face.h" #include "sunrise_sunset_face.h" #include "moonrise_face.h" #include "moon_phase_face.h" diff --git a/watch-faces.mk b/watch-faces.mk index 09fb6e94..dbed7113 100644 --- a/watch-faces.mk +++ b/watch-faces.mk @@ -9,6 +9,7 @@ SRCS += \ ./watch-faces/complication/countdown_face.c \ ./watch-faces/complication/stopwatch_face.c \ ./watch-faces/complication/fast_stopwatch_face.c \ + ./watch-faces/complication/billing_face.c \ ./watch-faces/complication/sunrise_sunset_face.c \ ./watch-faces/complication/moonrise_face.c \ ./watch-faces/complication/moon_phase_face.c \ diff --git a/watch-faces/complication/billing_face.c b/watch-faces/complication/billing_face.c new file mode 100644 index 00000000..996ffcb1 --- /dev/null +++ b/watch-faces/complication/billing_face.c @@ -0,0 +1,245 @@ +/* + * MIT License + * + * Copyright (c) 2025 OpenCode + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include +#include +#include +#include +#include "billing_face.h" +#include "watch.h" +#include "watch_utility.h" +#include "watch_common_display.h" +#include "watch_rtc.h" + +// distant future for the background task: January 1, 2083. +// while the timer is running we schedule a task that will never fire, which +// keeps the watch from entering low energy mode and displaying stale data. +static const watch_date_time_t distant_future = { + .unit = {0, 0, 0, 1, 1, 63} +}; + +#define BILLING_DEFAULT_RATE 2500 // 2500 monies/hour + +// Billing math: RTC ticks at 128 Hz, rate in money, all 64-bit integer. +#define BILLING_TICKS_PER_HOUR (128u * 3600u) + +static uint64_t billing_compute(uint32_t rate_money_per_hour, uint64_t elapsed_ticks) { + return (uint64_t)rate_money_per_hour * elapsed_ticks / BILLING_TICKS_PER_HOUR; +} + +// bottom display: money, saturated at 999999 when it no longer fits in 6 digits. +static uint32_t billing_display(uint64_t total) { + return total > 999999u ? 999999u : (uint32_t)total; +} + +// two most-significant digits of the rate +static uint32_t billing_rate_digits(uint32_t rate) { + while (rate > 99) rate /= 10; + return rate; +} + +static inline void _button_beep(void) { + if (movement_button_should_sound()) { + watch_buzzer_play_note_with_volume(BUZZER_NOTE_C7, 50, movement_button_volume()); + } +} + +static void _redraw(billing_state_t *state, rtc_counter_t now) { + uint64_t elapsed = state->running ? ((uint64_t)now - (uint64_t)state->start_counter) : (uint64_t)state->paused_counter; + uint64_t total_money = billing_compute(state->rate_money_per_hour, elapsed); + + // space-padded, not zero-padded — and full width, so stale digits clear + char buf[8]; + sprintf(buf, "%6lu", (unsigned long)billing_display(total_money)); + watch_display_text(WATCH_POSITION_BOTTOM, buf); + + sprintf(buf, "%2lu", (unsigned long)billing_rate_digits(state->rate_money_per_hour)); + watch_display_text(WATCH_POSITION_TOP_RIGHT, buf); +} + +static void _editor_inc_digit(billing_state_t *state) { + uint32_t place = 1u; + for (uint8_t i = state->cursor; i < 5; i++) place *= 10u; + if (((state->rate_money_per_hour / place) % 10u) == 9) { + state->rate_money_per_hour -= place * 9u; // 9 -> 0, no carry + } else { + state->rate_money_per_hour += place; + } +} + +static void _redraw_editor(billing_state_t *state, uint8_t subsecond) { + uint32_t rate = state->rate_money_per_hour; + char buf[14]; + sprintf(buf, "%06lu", (unsigned long)rate); + watch_display_text(WATCH_POSITION_BOTTOM, buf); + + if (subsecond % 2) watch_display_character(' ', 4 + state->cursor); + + watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "RATE", "RA"); +} + +static void _show_bill_label(void) { + watch_display_text_with_fallback(WATCH_POSITION_TOP_LEFT, "BILL", "BL"); +} + +static void _exit_editor(billing_state_t *state, rtc_counter_t now) { + state->mode = 0; + state->editor_quick = false; + movement_request_tick_frequency(state->running ? 32 : 1); + _show_bill_label(); + _redraw(state, now); +} + +void billing_face_setup(uint8_t watch_face_index, void ** context_ptr) { + (void) watch_face_index; + if (*context_ptr == NULL) { + *context_ptr = malloc(sizeof(billing_state_t)); + memset(*context_ptr, 0, sizeof(billing_state_t)); + billing_state_t *state = (billing_state_t *)*context_ptr; + state->rate_money_per_hour = BILLING_DEFAULT_RATE; + } +} + +void billing_face_activate(void *context) { + billing_state_t *state = (billing_state_t *)context; + watch_clear_colon(); + _show_bill_label(); + movement_request_tick_frequency(state->running ? 32 : 1); + if (state->running) { + movement_schedule_background_task(distant_future); + } +} + +bool billing_face_loop(movement_event_t event, void *context) { + billing_state_t *state = (billing_state_t *)context; + rtc_counter_t now = watch_rtc_get_counter(); + + switch (event.event_type) { + case EVENT_ACTIVATE: + if (state->mode == 1) _redraw_editor(state, 0); + else _redraw(state, now); + break; + case EVENT_TICK: + if (state->mode == 1) { + if (state->editor_quick) _editor_inc_digit(state); + _redraw_editor(state, event.subsecond); + } else { + _redraw(state, now); + } + break; + case EVENT_ALARM_BUTTON_DOWN: + if (state->mode == 1) { + _button_beep(); + _editor_inc_digit(state); + _redraw_editor(state, 0); + } else { + _button_beep(); + if (state->running) { + state->paused_counter = now - state->start_counter; + state->running = false; + movement_cancel_background_task(); + movement_request_tick_frequency(1); + } else { + state->start_counter = now - state->paused_counter; + state->running = true; + movement_request_tick_frequency(32); + movement_schedule_background_task(distant_future); + } + _redraw(state, now); + } + break; + case EVENT_ALARM_LONG_PRESS: + if (state->mode == 1) { + state->editor_quick = true; + movement_request_tick_frequency(8); + } + break; + case EVENT_ALARM_LONG_UP: + if (state->mode == 1) { + state->editor_quick = false; + movement_request_tick_frequency(4); + } + break; + case EVENT_LIGHT_BUTTON_DOWN: + if (state->mode == 1) { + state->cursor = (uint8_t)((state->cursor + 1) % 6); + _redraw_editor(state, 0); + } else if (!state->running) { + // reset the timer while stopped (matches the stopwatch face) + state->start_counter = 0; + state->paused_counter = 0; + _redraw(state, now); + } + break; + case EVENT_LIGHT_LONG_PRESS: + if (state->mode == 1) { + _exit_editor(state, now); + } else { + // enter the rate editor + state->mode = 1; + state->cursor = 0; + state->saved_rate = state->rate_money_per_hour; + state->editor_quick = false; + movement_request_tick_frequency(4); + _redraw_editor(state, 0); + } + break; + case EVENT_MODE_BUTTON_UP: + if (state->mode == 1) { + // Mode confirms and exits the editor, staying on this face. + _exit_editor(state, now); + return true; + } + return movement_default_loop_handler(event); + case EVENT_TIMEOUT: + if (state->mode == 1) { + // walked away mid-edit: discard the edit and leave the editor, + // so we don't idle at 4 Hz forever with a half-entered rate + state->rate_money_per_hour = state->saved_rate; + _exit_editor(state, now); + } + // otherwise stay on screen, just like the stopwatch face + break; + case EVENT_LOW_ENERGY_UPDATE: + if (state->mode == 0) { + if (!state->running) { + // show a blank display so it's clear we're in sleep mode + watch_display_text(WATCH_POSITION_BOTTOM, "------"); + } else { + _redraw(state, now); + } + } + break; + default: + return movement_default_loop_handler(event); + } + + return true; +} + +void billing_face_resign(void *context) { + (void) context; + movement_cancel_background_task(); + movement_request_tick_frequency(1); +} diff --git a/watch-faces/complication/billing_face.h b/watch-faces/complication/billing_face.h new file mode 100644 index 00000000..120201ed --- /dev/null +++ b/watch-faces/complication/billing_face.h @@ -0,0 +1,79 @@ +/* + * MIT License + * + * Copyright (c) 2025 OpenCode + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef BILLING_FACE_H_ +#define BILLING_FACE_H_ + +/* + * BILLING FACE + * + * A stopwatch-style face that accrues money instead of elapsed time. You set an + * hourly rate (in generic monetary units) using an on-face editor, and the face + * displays the current amount billed for the time elapsed since you started it. + * + * Controls: + * - Alarm button: start / stop the billing timer. + * - Light button (when stopped): reset the timer. + * - Light button long press: enter the rate editor (or, inside the editor, + * save the rate and exit). + * - Inside the editor: + * - Light button: move to the next digit. + * - Alarm button: increment the current digit. + * - Alarm button long press: rapidly increment the current digit. + * - Mode button or Light long press: save and exit. + * + * Display (money only): + * - Bottom 6 digits: the amount billed, space-padded (no leading zeros), + * saturating at 999999 when it no longer fits in 6 digits. + * - Top-right 2 digits: the two most-significant digits of the hourly rate, + * as a reminder of what you're billing at. + */ + +#include "movement.h" + +typedef struct { + bool running; + rtc_counter_t start_counter; // virtual 128 Hz start time (so resume is accurate) + rtc_counter_t paused_counter; // accumulated ticks while stopped + uint32_t rate_money_per_hour; // hourly rate + uint32_t saved_rate; // rate at editor entry, restored if the edit times out + uint8_t mode; // 0 = normal, 1 = editing rate + uint8_t cursor; // active digit (0..5) while editing + bool editor_quick; // auto-increment while Alarm is held +} billing_state_t; + +void billing_face_setup(uint8_t watch_face_index, void ** context_ptr); +void billing_face_activate(void *context); +bool billing_face_loop(movement_event_t event, void *context); +void billing_face_resign(void *context); + +#define billing_face ((const watch_face_t){ \ + billing_face_setup, \ + billing_face_activate, \ + billing_face_loop, \ + billing_face_resign, \ + NULL, \ +}) + +#endif // BILLING_FACE_H_