/* * 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); }