diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1491d81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + name: host tests + JS syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Pure-logic modules compile and run on the host (no Pebble SDK needed), + # covering layout, calendar, suntimes, time/date formatting, vibes and the + # clock-font selection. The full per-platform SDK build + warning-strict + # compile run locally via `tools/test.sh`. + - name: Host unit tests + run: | + gcc -std=c11 -DUNIT_TEST -Isrc -Itests tests/test_*.c \ + src/layout.c src/calendar.c src/suntimes.c src/timefmt.c src/vibes.c src/math.c \ + -lm -o timely_utest + ./timely_utest + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: PebbleKit JS syntax check + run: | + for f in src/js/*.js; do + echo "checking $f" + node --check "$f" + done diff --git a/.gitignore b/.gitignore index 3fe2331..f11f99c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ pebble-screenshot* # Local-only workflow artifacts (brainstorming specs / plans) docs/superpowers/ + +node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..63892f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +# Dev tasks for TimelyNG. Equivalent recipes exist in the justfile. +# Host unit tests: run inside `nix develop .#test` (needs a host cc) +# Pebble app + emulator: run inside `nix develop` (needs pebble CLI) + +CC ?= cc +CFLAGS ?= -I src -I tests -Wall -Wextra -std=c11 +BUILD := build +TEST_BIN := $(BUILD)/test_suite +TEST_SRC := $(wildcard tests/*.c) src/timefmt.c src/layout.c src/calendar.c src/vibes.c src/suntimes.c src/math.c + +# emulator platform: aplite | basalt | chalk | diorite | emery | flint +EMU ?= emery +SHOT ?= $(BUILD)/screenshot-$(EMU).png + +.PHONY: help test test-xml test-clean build install run shot config preview logs kill app-clean sdk + +help: + @echo "Host tests (nix develop .#test): test test-xml test-clean" + @echo "Pebble (nix develop): build run config preview shot logs kill app-clean sdk" + @echo "Guided picker (just): just menu (or: just config)" + @echo "Vars: EMU=$(EMU) SHOT=$(SHOT)" + +## ---- host unit tests ---- +test: $(TEST_BIN) + $(TEST_BIN) + +test-xml: $(TEST_BIN) + $(TEST_BIN) --output=$(BUILD)/test-results.xml + +$(TEST_BIN): $(TEST_SRC) $(wildcard src/*.h tests/*.h) + mkdir -p $(BUILD) + $(CC) $(CFLAGS) $(TEST_SRC) -o $(TEST_BIN) + +test-clean: + rm -f $(TEST_BIN) $(BUILD)/test-results.xml + +## ---- Pebble app + emulator ---- +build: + pebble build + +run install: build ## build + (re)install on the emulator + pebble install --emulator $(EMU) + +config: build ## build + install + open the offline config page (EMU=$(EMU); use `just menu` for a picker) + pebble install --emulator $(EMU) + node tools/gen-config-html.js $(BUILD)/config.html + pebble emu-app-config --emulator $(EMU) --file $(BUILD)/config.html + +preview: ## render the config page to build/config.html to open in a browser + node tools/gen-config-html.js $(BUILD)/config.html + @echo "open $(BUILD)/config.html" + +shot: build ## build + install + grab a screenshot to $(SHOT) + pebble install --emulator $(EMU) + sleep 4 + pebble screenshot $(SHOT) --no-open + @echo "saved $(SHOT)" + +logs: ## stream app logs from the emulator + pebble logs --emulator $(EMU) + +kill: ## stop the running emulator + pebble kill + +app-clean: ## pebble clean (forces SDK reconfigure) + pebble clean + +sdk: ## install the Pebble SDK (first-time setup) + pebble sdk install latest diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..184bd59 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,81 @@ +# Publishing TimelyNG to the Pebble / Rebble appstore + +Listing copy and asset specs, ready to paste into the +[Rebble developer portal](https://dev-portal.rebble.io/). Field set per the +Rebble guides: +[Publishing an App](https://developer.rebble.io/guides/appstore-publishing/publishing-an-app/), +[Submit an app](https://help.rebble.io/appstore-submission/?viewall=true), +[Appstore assets](https://developer.rebble.io/guides/appstore-publishing/appstore-assets/). + +## Basic details + +- **Title:** TimelyNG +- **Type:** Watchface (no large/small icon step required for watchfaces) +- **Category:** Watchfaces +- **UUID:** `de253577-14c6-4ab2-9db6-65bda1c7c1d2` +- **Source code:** https://github.com/eldios/TimelyNG +- **Website:** https://github.com/eldios/TimelyNG +- **Support:** https://github.com/eldios/TimelyNG/issues (or a support email) + +## Description + +> A calendar watchface with complications you arrange yourself. +> +> TimelyNG shows the date, time and weather up top and a compact three‑week +> calendar (previous · current · next) below. Around them are three configurable +> rows — a status bar, a row above the time, and a row above the calendar — and +> each can hold one complication (centred) or two side by side. +> +> Pick from one shared menu for every slot: date, day, month, ISO week number, +> day‑of‑year / days‑left, seconds, AM/PM, timezone, a second time zone, +> sunrise, sunset, moon phase, weather location, watch battery, phone battery +> and Bluetooth. Battery can be a filling bar with the percentage inside, plain +> text, an icon with text, or a bar with an icon. +> +> The layout is adaptive: leave a row empty and the clock and calendar grow to +> use the space, with the clock font scaling to fit — and everything updates the +> moment you save your settings. +> +> Weather comes from Open‑Meteo over HTTPS (no API key). Configuration is 100 % +> offline — no server, no account. Vibration (hourly, connect/disconnect), +> Do‑Not‑Disturb, themes and full localization are all built in. +> +> Freely inspired by, and an homage to, the original Timely / PebbleTimely and +> the Timely‑2 and PebbleTimely community forks. + +**Keywords / tags:** calendar, weather, complications, date, week number, +battery, moon phase, world time, minimal, localizable + +## Release notes — v0.0.1 + +> First public release. +> +> • Configurable complication rows (status bar, above the time, above the +> calendar) — one centred or two side by side per row. +> • One shared, alphabetical menu for every slot, incl. watch/phone battery, +> Bluetooth and the date. +> • Adaptive layout that reflows live on save; clock font auto‑scales. +> • Battery as a filling bar (% inside), text, icon + text, or bar + icon. +> • Open‑Meteo weather (no API key); fully offline configuration. +> • Builds for Pebble Time 2 (emery), Pebble 2 Duo (flint), basalt and diorite. + +## Assets + +Create an asset collection per supported platform (`emery`, `basalt`, +`diorite`, `flint`). Per the Rebble guides: + +- **Screenshots:** up to 5 per platform, at the watch's native resolution, + **unframed** (the listing must not use framed screenshots): + - `emery` — 200×228 + - `basalt` / `diorite` / `flint` — 144×168 + - Capture with `pebble screenshot --emulator `. +- **Marketing banner:** optional for watchfaces (required only for apps). + Templates: see the Rebble appstore‑assets guide. +- **Icons:** not required for watchfaces. + +## Publish flow + +1. `pebble build` → upload `build/TimelyNG.pbw` with the release notes above. +2. Add an asset collection (description + screenshots) for each platform. +3. Click **Publish** (or **Publish Privately**), then reload for the public + appstore link. diff --git a/README b/README deleted file mode 100644 index 5253471..0000000 --- a/README +++ /dev/null @@ -1,25 +0,0 @@ -Timely Watchface for Pebble - -This is a Watchface for the Pebble Watch. It shows the current date and current time in the top 'half', optionally including the weather conditions and temperature, and then a small calendar w/ 3 weeks (current and any 2 weeks around it), in the bottom 'half'. The statusbar at the top shows the connection status, charging, and battery level. The text and date formats on the watch are fully localizable through the configuration screen, and various display options/preferences can be configured including vibrating in unique ways (or not at all) on the hour, and on link lost/regained. - -The settings and localization for the face are configurable using the new PebbleKit JS configuration page. - -Special thanks: - - 'Climacons' by @adamwhitcroft, compiled into a TTF font by @christiannaths - - http://adamwhitcroft.com/climacons/ - -License - -Timely watchface [https://github.com/cynorg/PebbleTimely] -Copyright (C) 2013 Martin Norland - -Timely Watchface for Pebble is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - -Timely Watchface for Pebble is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - -Includes updates, contributions and improvements from Ben Johnson [https://github.com/cktben/PebbleTimely.git] - -Original PebbleCalendarWatch License [https://github.com/WilliamHeaton/PebbleCalendarWatch] -Copyright (C) 2013 William Heaton - -You should have received a copy of the GNU General Public License along with Timely Watchface for Pebble. If not, see http://www.gnu.org/licenses/. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d546e9 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +
+ +# ⌚ TimelyNG + +**A calendar watchface for Pebble — date, time & weather up top, a 3‑week +calendar below, and fully configurable complication rows you arrange yourself.** + +[![CI](https://github.com/eldios/TimelyNG/actions/workflows/ci.yml/badge.svg)](https://github.com/eldios/TimelyNG/actions/workflows/ci.yml) +![Version](https://img.shields.io/badge/version-0.0.1-blue) +![Platforms](https://img.shields.io/badge/Pebble-emery%20·%20flint%20·%20basalt%20·%20diorite-orange) +![Language](https://img.shields.io/badge/C-C99-555?logo=c) +![PebbleKit JS](https://img.shields.io/badge/config-PebbleKit%20JS-f5a623?logo=javascript&logoColor=white) +![SDK](https://img.shields.io/badge/Pebble%20SDK-4.x%20(Core%20Devices)-black) + +
+ +--- + +## 🙏 Homage & inspiration + +TimelyNG is **freely inspired by, and an homage to, the original *Timely* / +*PebbleTimely* watchface and its community forks** — in particular +[Andrew129260/Timely‑2](https://github.com/Andrew129260/Timely-2) and +[alan‑johnson/PebbleTimely](https://github.com/alan-johnson/PebbleTimely). +It carries forward their ideas and spirit (the calendar‑centric layout, the +localizable date/time, the complication slots), rebuilt for the modern +**Core Devices Pebble SDK** and the new hardware. All credit for the original +concept goes to those projects; any bugs here are mine. + +## ✨ Why TimelyNG (what's different) + +Compared to the classic Timely and its forks, TimelyNG adds: + +- **Configurable complication *rows*, not fixed slots.** Three rows — status + bar, above the time, above the calendar — and each holds **one complication + (centred) or two (side by side)**. +- **One unified, alphabetical menu for every slot**, including things that used + to be hard‑wired: **watch battery, phone battery, Bluetooth, and the date + itself** — put any of them anywhere. +- **Adaptive layout that reflows live.** Empty a row and the freed space is + given back to the clock and calendar; the **clock font auto‑scales** to the + space available — and it all updates the moment you save the config, no + reload. +- **Multiple battery styles:** a filling bar with the % *inside*, plain text, + icon + text, or bar + icon. +- **Open‑Meteo weather over HTTPS, no API key**, sized per screen. +- **100 % offline configuration** — no server, no CDN, no external host. +- **Built for 2026 hardware** (Pebble Time 2, Pebble 2 Duo) on the current SDK, + with a local test gate that mirrors the cloud's strict build flags. + +## 🧩 What it shows + +- **Complications** (shared menu): date, day, month, week number, + day‑of‑year / days‑left, seconds, AM/PM, timezone, a second time zone, + sunrise, sunset, moon phase, weather location, watch/phone battery, Bluetooth. +- **Weather:** condition icon + temperature (Open‑Meteo). +- **Calendar:** previous / current / next week, today accented on colour + watches. +- Vibration (hourly, on disconnect / reconnect), Do‑Not‑Disturb, themes and + full localization — all from the config screen. + +## ⌚ Platforms + +| Platform | Watch | Screen | Status | +| --------- | -------------------------- | --------------- | ------ | +| `emery` | Pebble Time 2 | colour 200×228 | ✅ Supported | +| `flint` | Pebble 2 Duo / Core 2 Duo | B&W 144×168 | ✅ Supported | +| `basalt` | Pebble Time / Time Steel | colour 144×168 | ✅ Supported | +| `diorite` | Pebble 2 | B&W 144×168 | ✅ Supported | +| `aplite` | Pebble (2013) / Steel | B&W 144×168 | ⏳ Dropped — future (24 KB RAM, needs a code diet) | +| `chalk` | Pebble Time Round | colour 180×180 round | 🔜 Not supported — future (round layout) | +| `gabbro` | Core Time 2 | colour 260×260 round | 🔜 Not supported — future (round layout) | + +The round platforms (`chalk` / `gabbro`) need a dedicated round layout — the +current design is rectangle‑centric. `aplite` was dropped for RAM. See +[`TODO.md`](TODO.md). + +## 🔨 Building (Nix + official Pebble CLI) + +```sh +nix develop # pebble CLI + ARM toolchain + emulator +pebble build # -> build/TimelyNG.pbw +pebble install --emulator emery # colour emulator (or basalt / diorite / flint) +``` + +Build/test exactly as the cloud does (catches the `-Werror` / warnings the +local build relaxes via `pbl_suppress_newer_gcc_warnings`): + +```sh +nix develop -c sh tools/test.sh # host unit tests + build + strict per‑platform compile +``` + +## ⚙️ Configuration + +The settings screen is fully offline. On `showConfiguration` the watch's +PebbleKit JS builds the page from `src/js/config.js` (the field spec) via +`src/js/configpage.js` and hands it to the phone as a `data:text/html` URI; the +page returns the chosen values via `pebblejs://close#`. Edit +`src/js/config.js` to change the options. + +## 📦 Publishing + +Store listing copy (description, release notes, categories, asset specs) lives +in [`PUBLISHING.md`](PUBLISHING.md), ready to paste into the +[Rebble developer portal](https://dev-portal.rebble.io/). diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6ab14ab --- /dev/null +++ b/TODO.md @@ -0,0 +1,58 @@ +# TODO + +Future work for TimelyNG. Roughly ordered by priority. + +## Platforms +- **Re-add `aplite`** (original 2013 Pebble, 24 KB app RAM). It was dropped + because the current feature set no longer fits (≈19 KB of code leaves almost + no heap, so it faulted on launch). Bringing it back needs an aplite-specific + code diet behind `#ifdef PBL_PLATFORM_APLITE` — trim optional complications, + extra icons and large fonts — and a build there that runs without faulting + before it goes back into `package.json` `targetPlatforms`. +- **Round platforms** (`chalk`, `gabbro`). The layout is rectangle-centric; + a round variant would need its own geometry in `layout.c`. + +## Code cleanup +- **Remove the retired middle `day_layer`** complication slot. It is always + hidden (the row model is now left/right only) but still created, themed and + destroyed; also drop the now-unused `show_day` setting + `AK_STYLE_DAY` + handling and the `STAT_BT_ICON_*` defines left over from the old fixed BT icon. + +## Features / polish +- More complications (e.g. step count / health, a configurable text label). +- Per-complication formatting options where it makes sense. +- Revisit weather error/backoff behaviour for flaky phone links. + +## Project / infra +- **Add a `LICENSE`** (the upstream Timely/PebbleTimely license should be + checked and honoured; pick a compatible one). +- **CI: add the full SDK build** (per-platform `pebble build` + `tools/test.sh` + strict-check) once a stable Core Devices Pebble SDK container image is + available. CI currently runs the host unit tests + JS syntax check. +- Add appstore screenshots/banner under a `marketing/` folder (see + `PUBLISHING.md`). + +## Known limitations +- The weather location name is only shown when picked as the "Location" + complication; there is no automatic fallback display. + +## Inherited TODO (from the original Timely) + +Carried over from `TODO`/`XXX` markers in the upstream code — features that were +stubbed but never finished. Decide per item whether to implement or remove the +dead scaffolding (message keys, settings fields). + +- **Idle reminder** — a "you've been still, move" style reminder. The keys exist + but are unused: `idle_reminder`, `idle_vibe_patt`, `idle_message`, + `idle_start`, `idle_stop` (all marked `// TODO, UNUSED` in `Timely.c`). + Implement it or drop the keys + config. +- **Per-slot colour inversion** — `inv_slot_stat` / `inv_slot_top` / + `inv_slot_bot` are read but do nothing. Wire them up or remove. +- **`show_date` toggle** — the `show_date` key is read but unused (the date is a + complication now). Remove it, or repurpose as a quick date on/off. +- **Per-weekday hourly vibration** — `vibe_days` was meant to limit the hourly + buzz to chosen days; today it only encodes the schedule mode. Restore an + actual day-of-week picker, or document it as mode-only. +- **Real bold Unicode font** — non-Latin (e.g. RU) "today" header is faked with + a double-strike because there is no bold unicode font; add one for a clean + bold across all languages. diff --git a/appinfo.json b/appinfo.json deleted file mode 100644 index 0f9d86e..0000000 --- a/appinfo.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "uuid": "55f7744b-f586-45b8-854c-554f34489f20", - "shortName": "Timely", - "longName": "Timely 2.6", - "companyName": "Martin Norland (@cynorg)", - "versionCode": 2, - "versionLabel": "2.6", - "sdkVersion": "3", - "targetPlatforms": [ - "aplite", - "basalt" - ], - "watchapp": { - "watchface": true - }, - "capabilities": [ - "location", - "configurable" - ], - "appKeys": { - "style_inv": 0, - "style_day_inv": 1, - "style_grid": 2, - "vibe_hour": 3, - "intl_dowo": 4, - "intl_fmt_date": 5, - "style_am_pm": 6, - "style_day": 7, - "style_week": 8, - "intl_fmt_week": 9, - "debugging_on": 10, - "vibe_pat_disconnect": 11, - "vibe_pat_connect": 12, - "strftime_format": 13, - "track_battery": 14, - "language": 15, - "debuglang_on": 16, - "cal_week_pattern": 17, - "inv_slot_stat": 18, - "inv_slot_top": 19, - "inv_slot_bot": 20, - "show_stat_bar": 21, - "show_stat_batt": 22, - "show_date": 23, - "dnd_start": 24, - "dnd_stop": 25, - "dnd_noaccel": 26, - "vibe_start": 27, - "vibe_stop": 28, - "vibe_days": 29, - "idle_reminder": 30, - "idle_vibe_patt": 31, - "idle_message": 32, - "idle_start": 33, - "idle_stop": 34, - "weather_fmt": 35, - "weather_update": 36, - "message_type": 99, - "send_batt_percent": 100, - "send_batt_charging": 101, - "send_batt_plugged": 102, - "timezone_offset": 103, - "send_watch_version": 104, - "send_config_version": 105, - "request_weather": 106, - "weather_temp": 107, - "weather_cond": 108, - "trans_abbr_sunday": 500, - "trans_abbr_monday": 501, - "trans_abbr_tuesday": 502, - "trans_abbr_wedsday": 503, - "trans_abbr_thursday": 504, - "trans_abbr_friday": 505, - "trans_abbr_saturday": 506, - "trans_january": 507, - "trans_february": 508, - "trans_march": 509, - "trans_april": 510, - "trans_may": 511, - "trans_june": 512, - "trans_july": 513, - "trans_august": 514, - "trans_september": 515, - "trans_october": 516, - "trans_november": 517, - "trans_december": 518, - "TODO___trans_alarm": 519, - "trans_sunday": 520, - "trans_monday": 521, - "trans_tuesday": 522, - "trans_wedsday": 523, - "trans_thursday": 524, - "trans_friday": 525, - "trans_saturday": 526, - "trans_connected": 527, - "trans_disconnected": 528, - "trans_time_am": 529, - "trans_time_pm": 530, - "trans_abbr_january": 531, - "trans_abbr_february": 532, - "trans_abbr_march": 533, - "trans_abbr_april": 534, - "trans_abbr_may": 535, - "trans_abbr_june": 536, - "trans_abbr_july": 537, - "trans_abbr_august": 538, - "trans_abbr_september": 539, - "trans_abbr_october": 540, - "trans_abbr_november": 541, - "trans_abbr_december": 542 - }, - "resources": { - "media": [ - { - "menuIcon": true, - "type": "png", - "name": "IMAGE_MENU_ICON_DARK", - "file": "images/menu_icon_timely_dark.png" - }, - { - "type": "png", - "name": "IMAGE_BT_NOLINK_ICON", - "file": "images/bluetooth_20_20.png" - }, - { - "type": "png", - "name": "IMAGE_BT_LINKED_ICON", - "file": "images/bluetooth_thick_20_20.png" - }, - { - "type": "png", - "name": "IMAGE_CHARGING_ICON", - "file": "images/charging_20_20.png" - }, - { - "type": "png", - "name": "IMAGE_HOURVIBE_ICON", - "file": "images/hourvibe_20_20.png" - }, - { - "type": "png", - "name": "IMAGE_DONOTDISTURB_ICON", - "file": "images/donotdisturb_20_20.png" - }, - { - "type": "font", - "characterRegex": "[ -~\u0400-\u0460\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u2026]", - "name": "FONT_UNICODE_BOLD_16", - "file": "fonts/unifont_latest.ttf" - }, - { - "type": "font", - "characterRegex": "[ -~\u0400-\u0460\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u2026]", - "name": "FONT_UNICODE_16", - "file": "fonts/unifont_latest.ttf" - }, - { - "type": "font", - "name": "FONT_FUTURA_CONDENSED_48", - "characterRegex": "[0-9:]", - "file": "fonts/futura_condensed_bold-webfont.ttf" - }, - { - "type": "font", - "name": "FONT_CLIMACONS_32", - "file": "fonts/Climacons.ttf" - } - ] - }, - "targetPlatforms": [ - "aplite", - "basalt" - ], - "sdkVersion": "3" -} diff --git a/flake.lock b/flake.lock index 52c2c00..21f4fa6 100644 --- a/flake.lock +++ b/flake.lock @@ -132,6 +132,10 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "nixpkgs": [ + "pebble", + "nixpkgs" + ], "pebble": "pebble" } }, diff --git a/flake.nix b/flake.nix index c7a90ec..1d96c2a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,31 +1,92 @@ { - description = "Timely watchface for Pebble - SDK dev shell (official pebble CLI)"; + description = "TimelyNG watchface for Pebble - SDK dev shell + host unit tests"; inputs = { pebble.url = "github:pebble-dev/pebble.nix"; flake-utils.url = "github:numtide/flake-utils"; + # Reuse pebble.nix's pinned nixpkgs (no extra fetch) for host tooling/tests. + nixpkgs.follows = "pebble/nixpkgs"; }; # Usage (official Pebble SDK flow, https://developer.repebble.com/sdk/): # nix develop # pebble sdk install latest # first time only; caches in ~/.pebble-sdk - # pebble build # produces build/Timely.pbw - # pebble install --emulator basalt # run in the QEMU emulator + # pebble build # produces build/PebbleTimely.pbw + # pebble install --emulator emery # color emulator (or basalt/flint/...) # pebble login && pebble install --cloudpebble # push to a paired watch + # Host unit tests (pure modules in src/, no Pebble SDK): + # nix flake check # reproducible, CI + # nix develop .#test --command just test # or: make test + # Tests live in a SEPARATE shell on purpose: a host C compiler in the pebble + # shell hijacks the ARM cross-build ("gcc: unrecognized option -mthumb"). outputs = - { pebble, flake-utils, ... }: - # Restrict to the systems pebble.nix actually builds for (no aarch64-linux), - # otherwise per-system access to pebble.* errors out on unsupported hosts. + { nixpkgs, pebble, flake-utils, ... }: flake-utils.lib.eachSystem [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ] - (system: { - # Interactive SDK shell: pebble-tool + ARM toolchain + QEMU emulator. - # Inside it you run the official `pebble` commands listed above. - devShells.default = pebble.pebbleEnv.${system} { - emulatorTarget = "basalt"; - }; - }); + ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + # Pebble SDK shell: pebble CLI + ARM toolchain + QEMU emulator, plus + # just/make task runners. No host cc here (it hijacks the ARM build). + devShells.default = pebble.pebbleEnv.${system} { + emulatorTarget = "emery"; + packages = [ + pkgs.just + pkgs.gnumake + pkgs.gum # interactive prompts for the guided `just` recipes + ]; + # This 2013 codebase under the modern arm-none-eabi gcc 14 trips many + # warnings the SDK promotes to errors. The SDK's own suppressions do + # not reach the new flint platform, so downgrade warnings to non-fatal + # here (the SDK itself already disables ~40 such classes). + shellHook = '' + export CFLAGS="-Wno-error" + ''; + }; + + # Host shell for the unit tests: C compiler + just/make. + devShells.test = pkgs.mkShell { + packages = [ + pkgs.gcc + pkgs.gnumake + pkgs.just + ]; + }; + + # Reproducible host run of the pure-module unit suite (no Pebble SDK). + checks.default = pkgs.runCommandCC "timelycolor-tests" { } '' + mkdir -p src tests + cp ${./src/timefmt.c} src/timefmt.c + cp ${./src/timefmt.h} src/timefmt.h + cp ${./src/layout.c} src/layout.c + cp ${./src/layout.h} src/layout.h + cp ${./src/calendar.c} src/calendar.c + cp ${./src/calendar.h} src/calendar.h + cp ${./src/vibes.c} src/vibes.c + cp ${./src/vibes.h} src/vibes.h + cp ${./src/suntimes.c} src/suntimes.c + cp ${./src/suntimes.h} src/suntimes.h + cp ${./src/math.c} src/math.c + cp ${./src/math.h} src/math.h + cp ${./tests/utest.h} tests/utest.h + cp ${./tests/test_main.c} tests/test_main.c + cp ${./tests/test_timefmt.c} tests/test_timefmt.c + cp ${./tests/test_layout.c} tests/test_layout.c + cp ${./tests/test_calendar.c} tests/test_calendar.c + cp ${./tests/test_vibes.c} tests/test_vibes.c + cp ${./tests/test_suntimes.c} tests/test_suntimes.c + cc -I src -I tests -Wall -Wextra -std=c11 \ + tests/test_main.c tests/test_timefmt.c tests/test_layout.c tests/test_calendar.c tests/test_vibes.c tests/test_suntimes.c \ + src/timefmt.c src/layout.c src/calendar.c src/vibes.c src/suntimes.c src/math.c -o test_suite + ./test_suite + touch $out + ''; + } + ); } diff --git a/justfile b/justfile new file mode 100644 index 0000000..d6c0c64 --- /dev/null +++ b/justfile @@ -0,0 +1,117 @@ +# Dev tasks for TimelyNG. Equivalent recipes exist in the Makefile. +# Host unit tests: run inside `nix develop .#test` +# Pebble app + emulator: run inside `nix develop` + +cc := env_var_or_default("CC", "cc") +cflags := "-I src -I tests -Wall -Wextra -std=c11" +build := "build" +emu := env_var_or_default("EMU", "emery") # aplite|basalt|chalk|diorite|emery|flint +shot := build / ("screenshot-" + emu + ".png") +test_src := "tests/*.c src/timefmt.c src/layout.c src/calendar.c src/vibes.c src/suntimes.c src/math.c" + +# list the available recipes +default: + @just --list + +## ---- host unit tests ---- +test: _build-tests + ./{{build}}/test_suite + +test-xml: _build-tests + ./{{build}}/test_suite --output={{build}}/test-results.xml + +_build-tests: + mkdir -p {{build}} + {{cc}} {{cflags}} {{test_src}} -o {{build}}/test_suite + +test-clean: + rm -f {{build}}/test_suite {{build}}/test-results.xml + +## ---- Pebble app + emulator ---- +# compile the .pbw +build: + pebble build + +# build + (re)install on the emulator (EMU=emery by default) +run: build + pebble install --emulator {{emu}} + +# build + install + grab a screenshot to build/screenshot-.png +shot: build + pebble install --emulator {{emu}} + sleep 4 + pebble screenshot {{shot}} --no-open + @echo "saved {{shot}}" + +# stream app logs from the emulator +logs: + pebble logs --emulator {{emu}} + +# stop the running emulator +kill: + pebble kill + +# pebble clean (forces SDK reconfigure) +app-clean: + pebble clean + +# install the Pebble SDK (first-time setup) +sdk: + pebble sdk install latest + +## ---- guided (interactive; uses gum for input, falls back without it) ---- + +# print a chosen emulator platform ($EMU wins, else gum picker, else emery) +_pick-emu: + #!/usr/bin/env bash + if [ -n "${EMU:-}" ]; then echo "$EMU" + elif command -v gum >/dev/null 2>&1; then + gum choose --header "Emulator platform:" emery flint basalt diorite aplite + else echo emery; fi + +# build, install, and open the offline config page to test themes (guided) +config: + #!/usr/bin/env bash + set -euo pipefail + command -v pebble >/dev/null 2>&1 || { echo "Run inside 'nix develop' first."; exit 1; } + p="$(just _pick-emu)" + echo "▸ $p: build → install → config" + pebble build + pebble install --emulator "$p" + node tools/gen-config-html.js {{build}}/config.html + echo "Config page opening for $p — pick a theme/mode and press Save; the watchface updates live." + # Emulator path: serve the page as a file:// URL. The browser keeps the + # emulator's ?return_to= in location.href there (a data: URI may drop it), so + # Save round-trips. On a real watch showConfiguration serves the same page as + # a data: URI and the page falls back to pebblejs://close#. + pebble emu-app-config --emulator "$p" --file {{build}}/config.html + +# render the config page to build/config.html to open in a desktop browser +preview: + node tools/gen-config-html.js {{build}}/config.html + @echo "open {{build}}/config.html" + +# one guided command: choose an action (and platform), then run it +menu: + #!/usr/bin/env bash + set -euo pipefail + command -v pebble >/dev/null 2>&1 || { echo "Run inside 'nix develop' first."; exit 1; } + if ! command -v gum >/dev/null 2>&1; then + echo "gum not found — use a direct recipe: just run | config | shot | logs | test"; exit 1 + fi + action=$(gum choose --header "What do you want to do?" \ + "Run on emulator" "Configure themes" "Screenshot" "Tail logs" "Host tests" "Install to watch") + case "$action" in + "Host tests") exec just test ;; + "Install to watch") + ip=$(gum input --placeholder "phone IP (Pebble app → Developer Connection)") + pebble build && pebble install --phone "$ip"; exit 0 ;; + esac + p=$(gum choose --header "Emulator platform:" emery flint basalt diorite aplite) + pebble build + case "$action" in + "Run on emulator") pebble install --emulator "$p" ;; + "Configure themes") pebble install --emulator "$p"; node tools/gen-config-html.js "{{build}}/config.html"; pebble emu-app-config --emulator "$p" --file "{{build}}/config.html" ;; + "Screenshot") pebble install --emulator "$p"; sleep 4; pebble screenshot "{{build}}/screenshot-$p.png" --no-open; echo "saved {{build}}/screenshot-$p.png" ;; + "Tail logs") pebble logs --emulator "$p" ;; + esac diff --git a/marketing/screenshots/basalt.png b/marketing/screenshots/basalt.png new file mode 100644 index 0000000..d69d23f Binary files /dev/null and b/marketing/screenshots/basalt.png differ diff --git a/marketing/screenshots/diorite.png b/marketing/screenshots/diorite.png new file mode 100644 index 0000000..1187893 Binary files /dev/null and b/marketing/screenshots/diorite.png differ diff --git a/marketing/screenshots/emery.png b/marketing/screenshots/emery.png new file mode 100644 index 0000000..e82caf2 Binary files /dev/null and b/marketing/screenshots/emery.png differ diff --git a/marketing/screenshots/flint.png b/marketing/screenshots/flint.png new file mode 100644 index 0000000..44ca0e9 Binary files /dev/null and b/marketing/screenshots/flint.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4dee041 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "TimelyNG", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "TimelyNG", + "version": "3.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1726fa5 --- /dev/null +++ b/package.json @@ -0,0 +1,202 @@ +{ + "name": "TimelyNG", + "author": "Emanuele 'Lele' Calo", + "version": "0.0.1", + "private": true, + "keywords": [ + "pebble-app" + ], + "pebble": { + "sdkVersion": "3", + "targetPlatforms": [ + "basalt", + "diorite", + "emery", + "flint" + ], + "enableMultiJS": true, + "capabilities": [ + "location", + "configurable" + ], + "projectType": "native", + "displayName": "TimelyNG", + "uuid": "de253577-14c6-4ab2-9db6-65bda1c7c1d2", + "watchapp": { + "watchface": true, + "hiddenApp": false, + "onlyShownOnCommunication": false + }, + "resources": { + "media": [ + { + "menuIcon": true, + "type": "png", + "name": "IMAGE_MENU_ICON_DARK", + "file": "images/menu_icon_timely_dark.png" + }, + { + "type": "png", + "name": "IMAGE_CHARGING_ICON", + "file": "images/charging_20_20.png" + }, + { + "type": "png", + "name": "IMAGE_WATCH_ICON", + "file": "images/watch_16.png" + }, + { + "type": "png", + "name": "IMAGE_PHONE_ICON", + "file": "images/phone_16.png" + }, + { + "type": "png", + "name": "IMAGE_BT_16_ICON", + "file": "images/bt_16.png" + }, + { + "type": "png", + "name": "IMAGE_HOURVIBE_ICON", + "file": "images/hourvibe_20_20.png" + }, + { + "type": "png", + "name": "IMAGE_DONOTDISTURB_ICON", + "file": "images/donotdisturb_20_20.png" + }, + { + "type": "font", + "characterRegex": "[ -~Ѐ-ѠÀ-ÖØ-öø-ÿ…]", + "name": "FONT_UNICODE_BOLD_16", + "file": "fonts/DejaVuSansCondensed-Bold.ttf" + }, + { + "type": "font", + "characterRegex": "[ -~Ѐ-ѠÀ-ÖØ-öø-ÿ…]", + "name": "FONT_UNICODE_16", + "file": "fonts/DejaVuSansCondensed.ttf" + }, + { + "type": "font", + "name": "FONT_CLIMACONS_40", + "file": "fonts/Climacons.ttf" + }, + { + "type": "font", + "name": "FONT_CLIMACONS_32", + "file": "fonts/Climacons.ttf" + }, + { + "type": "font", + "name": "FONT_CLIMACONS_28", + "file": "fonts/Climacons.ttf" + } + ] + }, + "messageKeys": { + "style_inv": 0, + "style_day_inv": 1, + "style_grid": 2, + "vibe_hour": 3, + "intl_dowo": 4, + "intl_fmt_date": 5, + "style_am_pm": 6, + "style_day": 7, + "style_week": 8, + "intl_fmt_week": 9, + "debugging_on": 10, + "vibe_pat_disconnect": 11, + "vibe_pat_connect": 12, + "strftime_format": 13, + "track_battery": 14, + "language": 15, + "debuglang_on": 16, + "cal_week_pattern": 17, + "inv_slot_stat": 18, + "inv_slot_top": 19, + "inv_slot_bot": 20, + "show_stat_bar": 21, + "show_stat_batt": 22, + "show_date": 23, + "dnd_start": 24, + "dnd_stop": 25, + "dnd_noaccel": 26, + "vibe_start": 27, + "vibe_stop": 28, + "vibe_days": 29, + "idle_reminder": 30, + "idle_vibe_patt": 31, + "idle_message": 32, + "idle_start": 33, + "idle_stop": 34, + "weather_fmt": 35, + "weather_update": 36, + "theme": 37, + "theme_mode": 38, + "message_type": 99, + "send_batt_percent": 100, + "send_batt_charging": 101, + "send_batt_plugged": 102, + "timezone_offset": 103, + "send_watch_version": 104, + "send_config_version": 105, + "request_weather": 106, + "weather_temp": 107, + "weather_cond": 108, + "weather_city": 109, + "phone_battery": 110, + "weather_lat": 111, + "weather_lon": 112, + "clock2_tz": 113, + "slot_stat_l": 114, + "slot_stat_r": 115, + "slot_ctr_l": 116, + "slot_ctr_r": 117, + "batt_style": 118, + "trans_abbr_sunday": 500, + "trans_abbr_monday": 501, + "trans_abbr_tuesday": 502, + "trans_abbr_wedsday": 503, + "trans_abbr_thursday": 504, + "trans_abbr_friday": 505, + "trans_abbr_saturday": 506, + "trans_january": 507, + "trans_february": 508, + "trans_march": 509, + "trans_april": 510, + "trans_may": 511, + "trans_june": 512, + "trans_july": 513, + "trans_august": 514, + "trans_september": 515, + "trans_october": 516, + "trans_november": 517, + "trans_december": 518, + "TODO___trans_alarm": 519, + "trans_sunday": 520, + "trans_monday": 521, + "trans_tuesday": 522, + "trans_wedsday": 523, + "trans_thursday": 524, + "trans_friday": 525, + "trans_saturday": 526, + "trans_connected": 527, + "trans_disconnected": 528, + "trans_time_am": 529, + "trans_time_pm": 530, + "trans_abbr_january": 531, + "trans_abbr_february": 532, + "trans_abbr_march": 533, + "trans_abbr_april": 534, + "trans_abbr_may": 535, + "trans_abbr_june": 536, + "trans_abbr_july": 537, + "trans_abbr_august": 538, + "trans_abbr_september": 539, + "trans_abbr_october": 540, + "trans_abbr_november": 541, + "trans_abbr_december": 542 + } + } +} diff --git a/resources/fonts/DejaVuSansCondensed-Bold.ttf b/resources/fonts/DejaVuSansCondensed-Bold.ttf new file mode 100644 index 0000000..959cfec Binary files /dev/null and b/resources/fonts/DejaVuSansCondensed-Bold.ttf differ diff --git a/resources/fonts/DejaVuSansCondensed.ttf b/resources/fonts/DejaVuSansCondensed.ttf new file mode 100644 index 0000000..311441b Binary files /dev/null and b/resources/fonts/DejaVuSansCondensed.ttf differ diff --git a/resources/fonts/futura_condensed_bold-webfont.ttf b/resources/fonts/futura_condensed_bold-webfont.ttf deleted file mode 100644 index 4395679..0000000 Binary files a/resources/fonts/futura_condensed_bold-webfont.ttf and /dev/null differ diff --git a/resources/fonts/unifont_latest.ttf b/resources/fonts/unifont_latest.ttf deleted file mode 100644 index 8515c8e..0000000 Binary files a/resources/fonts/unifont_latest.ttf and /dev/null differ diff --git a/resources/images/bluetooth_20_20.png b/resources/images/bluetooth_20_20.png deleted file mode 100644 index 0140a56..0000000 Binary files a/resources/images/bluetooth_20_20.png and /dev/null differ diff --git a/resources/images/bluetooth_thick_20_20.png b/resources/images/bluetooth_thick_20_20.png deleted file mode 100644 index 00bf81b..0000000 Binary files a/resources/images/bluetooth_thick_20_20.png and /dev/null differ diff --git a/resources/images/bt_16.png b/resources/images/bt_16.png new file mode 100644 index 0000000..6b9c5cf Binary files /dev/null and b/resources/images/bt_16.png differ diff --git a/resources/images/phone_16.png b/resources/images/phone_16.png new file mode 100644 index 0000000..9c85445 Binary files /dev/null and b/resources/images/phone_16.png differ diff --git a/resources/images/watch_16.png b/resources/images/watch_16.png new file mode 100644 index 0000000..30c7947 Binary files /dev/null and b/resources/images/watch_16.png differ diff --git a/src/Timely.c b/src/Timely.c index 80ba7b7..b49350c 100644 --- a/src/Timely.c +++ b/src/Timely.c @@ -1,6 +1,15 @@ #include -#include +#include "Timely.h" #include "effect_layer.h" +#include "timefmt.h" +#include "layout.h" +#include "calendar.h" +#include "calendar_view.h" +#include "vibes.h" +#include "ui.h" +#include "theme.h" +#include "splash.h" +#include "suntimes.h" #define DEBUGLOG 0 #define TRANSLOG 0 #define CONFIG_VERSION "2.6" @@ -16,7 +25,7 @@ * */ -static Window *window; +Window *window; static Layer *battery_layer; static Layer *datetime_layer; @@ -25,36 +34,38 @@ static TextLayer *time_layer; static TextLayer *week_layer; static TextLayer *ampm_layer; static TextLayer *day_layer; -static Layer *calendar_layer; -static Layer *splash_layer; -static Layer *weather_layer; +static TextLayer *ctr_r_layer; // center row (above time), right slot; date_layer is the left static Layer *statusbar; static Layer *slot_status; static Layer *slot_top; static Layer *slot_bot; static GFont unifont_16; static GFont unifont_16_bold; -static GFont cal_normal; -static GFont cal_bold; -static GFont climacons; +GFont cal_normal; +GFont cal_bold; +GFont climacons; -static BitmapLayer *bmp_connection_layer; -static GBitmap *image_connection_icon; -static GBitmap *image_noconnection_icon; static BitmapLayer *bmp_charging_layer; static GBitmap *image_charging_icon; static GBitmap *image_hourvibe_icon; static GBitmap *image_dnd_icon; -static TextLayer *text_connection_layer; +static BitmapLayer *bmp_phone_layer; // phone icon next to the phone-battery readout +static GBitmap *image_phone_icon; +static BitmapLayer *bmp_watch_layer; // watch icon next to the watch battery +static GBitmap *image_watch_icon; +static GBitmap *image_bt16_icon; // 16px Bluetooth glyph for the status-bar slots +static TextLayer *text_connection_layer; // status-bar slot, left (text) static TextLayer *text_battery_layer; static EffectLayer *inverter_layer; -static EffectLayer *battery_meter_layer; +static EffectLayer *battery_meter_layer; // proportional fill for the LEFT status battery (bar style) +static EffectLayer *batt_fill_r; // proportional fill for the RIGHT status battery (bar style) // battery info, instantiate to 'worst scenario' to prevent false hopes static uint8_t battery_percent = 10; static bool battery_charging = false; static bool battery_plugged = false; +static int phone_battery_percent = -1; // -1 = unknown (phone hasn't reported / API unavailable) AppTimer *battery_sending = NULL; AppTimer *timezone_request = NULL; AppTimer *weather_request = NULL; @@ -63,8 +74,7 @@ AppTimer *bottom_toggle = NULL; static bool bluetooth_connected = false; // suppress vibration static bool vibe_suppression = true; -#define TIMEZONE_UNINITIALIZED 80 -static int8_t timezone_offset = TIMEZONE_UNINITIALIZED; +int8_t timezone_offset = TIMEZONE_UNINITIALIZED; struct tm *currentTime; static int8_t seconds_shown = 0; static bool dnd_period_active = false; @@ -118,6 +128,8 @@ static bool showing_statusbar = true; #define AK_IDLE_STOP 34 // UNUSED #define AK_WEATHER_FMT 35 #define AK_WEATHER_UPDATE 36 +#define AK_THEME 37 +#define AK_THEME_MODE 38 #define AK_MESSAGE_TYPE 99 #define AK_SEND_BATT_PERCENT 100 @@ -129,6 +141,16 @@ static bool showing_statusbar = true; #define AK_REQUEST_WEATHER 106 #define AK_WEATHER_TEMP 107 #define AK_WEATHER_COND 108 +#define AK_WEATHER_CITY 109 +#define AK_PHONE_BATTERY 110 +#define AK_WEATHER_LAT 111 +#define AK_WEATHER_LON 112 +#define AK_CLOCK2_TZ 113 +#define AK_SLOT_STAT_L 114 +#define AK_SLOT_STAT_R 115 +#define AK_SLOT_CTR_L 116 +#define AK_SLOT_CTR_R 117 +#define AK_BATT_STYLE 118 #define AK_TRANS_ABBR_SUNDAY 500 #define AK_TRANS_ABBR_MONDAY 501 @@ -175,13 +197,14 @@ static bool showing_statusbar = true; #define AK_TRANS_ABBR_DECEMBER 542 // primary coordinates -#define DEVICE_WIDTH 144 -#define DEVICE_HEIGHT 168 -#define LAYOUT_STAT 0 // 20 tall -#define LAYOUT_SLOT_TOP 24 // 72 tall -#define LAYOUT_SLOT_BOT 96 // 72 tall, 4px gap above -#define LAYOUT_SLOT_HEIGHT 72 -#define STAT_BATT_LEFT 96 // LEFT + WIDTH + NIB_WIDTH <= 143 +static int DEVICE_WIDTH = 144; // recomputed per-screen by compute_layout() +static int DEVICE_HEIGHT = 168; +static int LAYOUT_STAT = 0; +static int LAYOUT_SLOT_TOP = 24; +static int LAYOUT_SLOT_BOT = 96; +static int LAYOUT_SLOT_HEIGHT = 72; // top slot (time/date) height +static int LAYOUT_SLOT_BOT_HEIGHT = 72; // bottom slot (calendar) height — differs from the top once capped +static int STAT_BATT_LEFT = 96; // right-aligned at runtime #define STAT_BATT_TOP 4 #define STAT_BATT_WIDTH 44 // should be divisible by 10, after subtracting 4 (2 pixels/side for the 'border') #define STAT_BATT_HEIGHT 15 @@ -189,18 +212,51 @@ static bool showing_statusbar = true; #define STAT_BATT_NIB_HEIGHT 5 // >= 3 #define STAT_BT_ICON_LEFT -2 // 0 #define STAT_BT_ICON_TOP 2 -#define STAT_CHRG_ICON_LEFT 76 +static int STAT_CHRG_ICON_LEFT = 76; // right-aligned at runtime #define STAT_CHRG_ICON_TOP 2 +void apply_center(void); // center row (above time): 1 slot full / 2 slots halves +void apply_bottom(void); // bottom row (above calendar): 1 slot full / 2 slots halves + // relative coordinates (relative to SLOTs) -#define REL_CLOCK_DATE_LEFT 2 -#define REL_CLOCK_DATE_TOP 0 -#define REL_CLOCK_DATE_HEIGHT 30 // date/time overlap, due to the way text is 'positioned' -#define REL_CLOCK_DATE_WIDTH 140 -#define REL_CLOCK_TIME_LEFT 0 -#define REL_CLOCK_TIME_TOP 7 -#define REL_CLOCK_TIME_HEIGHT 60 // date/time overlap, due to the way text is 'positioned' -#define REL_CLOCK_SUBTEXT_TOP 56 // time/ampm overlap, due to the way text is 'positioned' +static int REL_CLOCK_DATE_LEFT = 2; +static int REL_CLOCK_DATE_TOP = 0; +static int REL_CLOCK_DATE_HEIGHT = 30; +static int REL_CLOCK_DATE_WIDTH = 140; +static int REL_CLOCK_TIME_LEFT = 0; +static int REL_CLOCK_TIME_TOP = 7; +static int REL_CLOCK_TIME_HEIGHT = 60; +static int REL_CLOCK_SUBTEXT_TOP = 56; +static int CAL_WIDTH = 20; // calendar column width (recomputed at runtime) +static int CAL_HEIGHT = 18; // calendar row height (recomputed at runtime) + +// Recompute the runtime layout for the actual screen. Bands are adaptive: a row +// only reserves height when it is in use, so disabling rows grows the rest. +static void compute_layout(int w, int h) { + int has_top = adv_settings_get()->showStatus != 0; + int has_center = settings_get()->slot_ctr_l || settings_get()->slot_ctr_r; + int has_bottom = settings_get()->show_week || settings_get()->show_am_pm; + TimelyLayout L = layout_compute_rows(w, h, has_top, has_center, has_bottom); + DEVICE_WIDTH = w; DEVICE_HEIGHT = h; + LAYOUT_STAT = L.statusbar.y; + LAYOUT_SLOT_TOP = L.slot_top.y; + LAYOUT_SLOT_BOT = L.slot_bot.y; + LAYOUT_SLOT_HEIGHT = L.slot_top.h; + LAYOUT_SLOT_BOT_HEIGHT = L.slot_bot.h; + STAT_BATT_LEFT = L.battery.x; + STAT_CHRG_ICON_LEFT = L.chrg_icon_x; + REL_CLOCK_DATE_LEFT = L.clock_date.x; + REL_CLOCK_DATE_TOP = L.clock_date.y; + REL_CLOCK_DATE_HEIGHT = L.clock_date.h; + REL_CLOCK_DATE_WIDTH = L.clock_date.w; + REL_CLOCK_TIME_LEFT = L.clock_time.x; + REL_CLOCK_TIME_TOP = L.clock_time.y; + REL_CLOCK_TIME_HEIGHT = L.clock_time.h; + REL_CLOCK_SUBTEXT_TOP = L.subtext_top; + CAL_WIDTH = L.cal_cell_w; + CAL_HEIGHT = L.cal_cell_h; + layout_store(L); +} #define SLOT_ID_CLOCK_1 0 #define SLOT_ID_CALENDAR 1 @@ -210,99 +266,10 @@ static bool showing_statusbar = true; /* */ -weather_data weather = { - .current = 999, - .condition = {'h'}, - .requests = 0, - .failures = 0, -}; - -persist settings = { - .version = 12, - .inverted = 0, // no, dark - .day_invert = 1, // yes - .grid = 1, // yes - .vibe_hour = 0, // no - .dayOfWeekOffset = 0, // 0 - 6, Sun - Sat - .date_format = 0, // Month DD, YYYY - .show_am_pm = 0, // no AM/PM [0:Hide, 1:AM/PM, 2:TZ, 3:Week, 4:DoY, 5:DLiY, 6:Seconds] - .show_day = 0, // no day name [0:Hide, 1:Day, 2:Month, 3:TZ, 4:Week, 5:AM/PM 6:DoY/DLiY] - .show_week = 0, // no week number [0:Hide, 1:Week, 2:TZ, 3:AM/PM, 4:DoY, 5:DLiY, 6:Seconds] - .week_format = 0, // ISO 8601 - .vibe_pat_disconnect = 2, // double vibe - .vibe_pat_connect = 0, // no vibe - .strftime_format = "%Y-%m-%d", - .track_battery = 0, // no battery tracking by default -}; - -persist_months_lang lang_months = { - .monthsNames = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }, -}; - -persist_days_lang lang_days = { - .DaysOfWeek = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }, -}; - -persist_general_lang lang_gen = { - .statuses = { "Linked", "NOLINK" }, - .abbrTime = { "AM", "PM" }, - .abbrDaysOfWeek = { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" }, - .abbrMonthsNames = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }, - .language = "EN", -}; - -persist_debug debug = { - .general = false, // debugging disabled by default - .language = false, // debugging disabled by default - .reserved_1 = false, // debugging disabled by default - .reserved_2 = false, // debugging disabled by default - .reserved_3 = false, // debugging disabled by default - .reserved_4 = false, // debugging disabled by default -}; - -persist_adv_settings adv_settings = { - // Calendar week pattern - which weeks to show - .week_pattern = 0, // 0:lcn, 1:llc, 2:cnn, 3:lc, 4:cn, 5:c - // Inversions - .invertStatBar = 0, // 1: invert Statusbar - .invertTopSlot = 0, // 1: invert Top Slot - .invertBotSlot = 0, // 1: invert Bottom Slot - // Status bar auto-hiding... - .showStatus = 1, // Status: 0: never(!?), 1: always, 2: only when disconnected/charging/battery, 3: minimal always, 4: minimal #2 - .showStatusBat = 100, // Status: battery percentage above which to hide statusbar if hiding it is allowed - // Date hiding... because I didn't reserve 0 in date_format ;) - .showDate = 1, // Date: 0: never, 1: always - // DND start/stop (suppress vibrations, Weather updates, pretty much anything except updating the time) - // ...hopefully this will become pointless with a SDK update which exposes the watches DND... - .DND_start = 0, // Do Not Disturb: 10 minute increments, 0 = 12:00am, 144 = 12:00am (following day) - .DND_stop = 0, // Do Not Disturb: 10 minute increments, 0 = 12:00am, 144 = 12:00am (following day) - .DND_accel_off = 0, // Do Not Disturb: disable accelerometer polling during DND? - // hourly vibration start/stop - .vibe_hour_start = 0, // Hour Vibe: 10 minute increments, 0 = 12:00am, 144 = 12:00am (following day) - .vibe_hour_stop = 0, // Hour Vibe: 10 minute increments, 0 = 12:00am, 144 = 12:00am (following day) - .vibe_hour_days = 0, // Hour Vibe: days active [Su 1, Mo 2, Tu 4, We 8, Th 16, Fr 32, Sa 64 => 0 => 127] - // Idle reminders (accelerometer) - .idle_reminder = 0, // Idle: 0 = off; minutes 1 - 255 (0:01 to 4:15; 4 1/4 hours = meal/bathroom reminder, haha) - .idle_pattern = 0, // Idle: vibration pattern - .idle_message = { "Let's Move!" }, // Idle: reminder message - .idle_start = 0, // Idle: 10 minute increments, 0 = 12:00am, 143 = 12:00am (following day) - .idle_stop = 0, // Idle: 10 minute increments, 0 = 12:00am, 143 = 12:00am (following day) - // Second Clock - .clock2_tz = 0, // 2nd clock: tz offset in 15 minute increments - .clock2_desc = { "Second Clock" }, // 2nd clock: desc / city name of 2nd clock - // Weather - .weather_format = 0, // Weather: 0: fahrenheit, 1: celsius - .weather_update = 15, // Weather: minutes between weather updates [must divide into 60 cleanly] - .weather_lat = "", // latitude for 'static' weather lookups (GPS disabled) - .weather_lon = "", // longitude for 'static' weather lookups (GPS disabled) - // Font - .clock_font = 1, // 1: default, 2: ? - // Tokencode - .token_type = { 0, 0 }, // Token: Google/other tokencode(s) - .token_code = { "", "" }, // Token: Tokencode/seed - // Slots... will require either app mode or fancy acceleration to access this many slots ;) - .slots = { 0, 1, 2, 3, 0, 1, 0, 1, 0, 1 } // 0 = clock_1, 1 = calendar, 2 = weather, 3 = forecast -}; + + + + /* char *translate_error(AppMessageResult result) { @@ -327,258 +294,26 @@ char *translate_error(AppMessageResult result) { */ -// How many days are/were in the month -int daysInMonth(int mon, int year) { - mon++; // dec = 0|12, lazily optimized - - // April, June, September and November have 30 Days - if (mon == 4 || mon == 6 || mon == 9 || mon == 11) { - return 30; - } else if (mon == 2) { - // Deal with Feburary & Leap years - if (year % 400 == 0) { - return 29; - } else if (year % 100 == 0) { - return 28; - } else if (year % 4 == 0) { - return 29; - } else { - return 28; - } - } else { - // Most months have 31 days - return 31; - } -} - struct tm *get_time() { time_t tt = time(0); return localtime(&tt); } -void setColors(GContext* ctx) { - window_set_background_color(window, GColorBlack); - graphics_context_set_stroke_color(ctx, GColorWhite); - graphics_context_set_fill_color(ctx, GColorBlack); - graphics_context_set_text_color(ctx, GColorWhite); -} - -void setInvColors(GContext* ctx) { - window_set_background_color(window, GColorWhite); - graphics_context_set_stroke_color(ctx, GColorBlack); - graphics_context_set_fill_color(ctx, GColorWhite); - graphics_context_set_text_color(ctx, GColorBlack); -} - -void weather_layer_update_callback(Layer *me, GContext* ctx) { - (void)me; // 144x72 - static char temp_current[] = "N/A "; - static char cond_current[] = "0"; - if (weather.current < 900) { - snprintf(temp_current, sizeof(temp_current), "%d\u00b0", weather.current); - } else { - snprintf(temp_current, sizeof(temp_current), "N/A"); - } - snprintf(cond_current, sizeof(cond_current), "%s", weather.condition); - - setColors(ctx); - graphics_draw_text(ctx, cond_current, climacons, GRect(2,16,34,34), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - graphics_draw_text(ctx, temp_current, fonts_get_system_font(FONT_KEY_GOTHIC_24), GRect(2,42,36,36), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Weather redrawing: %d, %s", weather.current, weather.condition); } -} - -void splash_layer_update_callback(Layer *me, GContext* ctx) { - (void)me; // 144x72 - setColors(ctx); - graphics_draw_text(ctx, "Timely", fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), GRect(0,0,144,36), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - graphics_draw_text(ctx, CONFIG_VERSION, fonts_get_system_font(FONT_KEY_GOTHIC_28), GRect(0,32,144,36), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); -} - -void calendar_layer_update_callback(Layer *me, GContext* ctx) { - (void)me; - int mon = currentTime->tm_mon; - int year = currentTime->tm_year + 1900; - int daysThisMonth = daysInMonth(mon, year); - int specialDay = currentTime->tm_wday - settings.dayOfWeekOffset; // specialDay is the column [0-6] which holds the current day - /* We're going to build an array to hold the dates to be shown in the calendar. - * - * There are five 'parts' we'll calculate for this (though since we only display 3 weeks, we'll only ever see at most 4 of them) - * - * daysVisPrevMonth = days from the previous month that are visible - * daysPriorToToday = days before today (including any days from previous month) - * ( today ) - * daysAfterToday = days after today (including any days from next month) - * daysVisNextMonth = days from the following month that are visible - * - * daysPriorToToday + 1 + daysAfterToday = 21, since we display exactly 3 weeks. - */ - int show_last = 1; // number of previous weeks to show 0-2 - int show_next = 1; // number of future weeks to show 0-2 - switch ( adv_settings.week_pattern ) { - case 0: - break; - case 1: - show_last = 2; show_next = 0; - break; - case 2: - show_last = 0; show_next = 2; - break; -/* FIXME: not presently implemented to support this... need to calculate weeks and adjust how we do 'rows' below... - case 3: - show_last = 1; show_next = 0; - break; - case 4: - show_last = 0; show_next = 1; - break; - case 5: - show_last = 0; show_next = 0; - break; -*/ - } - - int calendar[21]; - int cellNum = 0; // address for current day table cell: 0-20 - int daysVisPrevMonth = 0; - int daysVisNextMonth = 0; - int daysPriorToToday = specialDay; // just instantiating, not final value - int daysAfterToday = (6 - specialDay) % 7; // just instantiating, not final value - - // tm_wday is based on Sunday being the startOfWeek, but Sunday may not be our startOfWeek. - if (currentTime->tm_wday < settings.dayOfWeekOffset) { - daysPriorToToday += 7 * (show_last+1); // we're <7, so in the 'first' week due to startOfWeek offset - 'add a week' before this one - specialDay += 7; - } else { - daysPriorToToday += 7 * show_last; // 0 = unchanged - } - daysAfterToday += 7 * show_next; // 0 = unchanged - - if ( daysPriorToToday >= currentTime->tm_mday ) { - // We're showing more days before today than exist this month - int daysInPrevMonth = daysInMonth(mon - 1,year); // year only matters for February, which will be the same 'from' March - - // Number of days we'll show from the previous month - daysVisPrevMonth = daysPriorToToday - currentTime->tm_mday + 1; - - for (int i = 0; i < daysVisPrevMonth; i++, cellNum++ ) { - calendar[cellNum] = daysInPrevMonth + i - daysVisPrevMonth + 1; - } - } - - // optimization: instantiate i to a hot mess, since the first day we show this month may not be the 1st of the month - int firstDayShownThisMonth = daysVisPrevMonth + currentTime->tm_mday - daysPriorToToday; - for (int i = firstDayShownThisMonth; i < currentTime->tm_mday; i++, cellNum++ ) { - calendar[cellNum] = i; - } - - //int currentDay = cellNum; // the current day... we'll style this special - calendar[cellNum] = currentTime->tm_mday; - cellNum++; - - if ( currentTime->tm_mday + daysAfterToday > daysThisMonth ) { - daysVisNextMonth = currentTime->tm_mday + daysAfterToday - daysThisMonth; - } - - // add the days after today until the end of the month/next week, to our array... - int daysLeftThisMonth = daysAfterToday - daysVisNextMonth; - for (int i = 0; i < daysLeftThisMonth; i++, cellNum++ ) { - calendar[cellNum] = i + currentTime->tm_mday + 1; - } - // add any days in the next month to our array... - for (int i = 0; i < daysVisNextMonth; i++, cellNum++ ) { - calendar[cellNum] = i + 1; - } +// Highlight colors for the current day's calendar cell. On color platforms the +// cell gets an accent fill (tune GColorJaegerGreen to taste); on B&W it falls +// back to the classic white-on-black inversion (identical to setInvColors). - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Calendar - DOWO: %d, wday: %d, sDay: %d, dVPM: %d, dVNM: %d, dPTT: %d, dAT: %d", settings.dayOfWeekOffset, currentTime->tm_wday, specialDay, daysVisPrevMonth, daysVisNextMonth, daysPriorToToday, daysAfterToday); } - -// --------------------------- -// Now that we've calculated which days go where, we'll move on to the display logic. -// --------------------------- - - #define CAL_DAYS 7 // number of columns (days of the week) - #define CAL_WIDTH 20 // width of columns - #define CAL_GAP 1 // gap around calendar - #define CAL_LEFT 2 // left side of calendar - #define CAL_HEIGHT 18 // How tall rows should be depends on how many weeks there are - - int weeks = 3; // always display 3 weeks: # previous, current, # next - - GFont current = cal_normal; - int font_vert_offset = 0; - if (strcmp(lang_gen.language,"RU") == 0 ) { font_vert_offset = -2; } - - // generate a light background for the calendar grid - if (settings.grid) { - setInvColors(ctx); - graphics_fill_rect(ctx, GRect (CAL_LEFT + CAL_GAP, CAL_HEIGHT - CAL_GAP, DEVICE_WIDTH - 2 * (CAL_LEFT + CAL_GAP), CAL_HEIGHT * weeks), 0, GCornerNone); - setColors(ctx); - } - for (int col = 0; col < CAL_DAYS; col++) { - // Adjust labels by specified offset - int weekday = col + settings.dayOfWeekOffset; - if (weekday > 6) { weekday -= 7; } - if (col == specialDay) { - current = cal_bold; - font_vert_offset = -3; - if (strcmp(lang_gen.language,"RU") == 0 ) { font_vert_offset = -2; } - } - // draw the cell background - // graphics_fill_rect(ctx, GRect (CAL_WIDTH * col + CAL_LEFT + CAL_GAP, 0, CAL_WIDTH - CAL_GAP, CAL_HEIGHT - CAL_GAP), 0, GCornerNone); - - // draw the cell text - graphics_draw_text(ctx, lang_gen.abbrDaysOfWeek[weekday], current, GRect(CAL_WIDTH * col + CAL_LEFT + CAL_GAP, CAL_GAP + font_vert_offset, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - if (col == specialDay) { - if (strcmp(lang_gen.language,"RU") == 0 ) { - // we don't actually have a bold font for this, so we'll use font double-striking to simulate bold - graphics_draw_text(ctx, lang_gen.abbrDaysOfWeek[weekday], current, GRect(CAL_WIDTH * col + CAL_LEFT + CAL_GAP + 1, CAL_GAP + font_vert_offset, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - } - current = cal_normal; - font_vert_offset = 0; - if (strcmp(lang_gen.language,"RU") == 0 ) { font_vert_offset = -2; } - } - } - GFont normal = fonts_get_system_font(FONT_KEY_GOTHIC_14); // fh = 16 - GFont bold = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); // fh = 22 - current = normal; - font_vert_offset = 0; - // draw the individual calendar rows/columns - int week = 0; - int specialRow = show_last+1; - - for (int row = 1; row <= 3; row++) { - week++; - for (int col = 0; col < CAL_DAYS; col++) { - if ( row == specialRow && col == specialDay) { - if (settings.day_invert) { - setInvColors(ctx); - } - current = bold; - font_vert_offset = -3; - } - - // draw the cell background - graphics_fill_rect(ctx, GRect (CAL_WIDTH * col + CAL_LEFT + CAL_GAP, CAL_HEIGHT * week, CAL_WIDTH - CAL_GAP, CAL_HEIGHT - CAL_GAP), 0, GCornerNone); - - // draw the cell text - char date_text[3]; - snprintf(date_text, sizeof(date_text), "%d", calendar[col + 7 * (row - 1)]); - graphics_draw_text(ctx, date_text, current, GRect(CAL_WIDTH * col + CAL_LEFT, CAL_HEIGHT * week - CAL_GAP + font_vert_offset, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); - - if ( row == specialRow && col == specialDay) { - setColors(ctx); - current = normal; - font_vert_offset = 0; - } - } - } -} -void update_date_text() { +// Format the current date per the configured format into a reusable static +// buffer (returned). update_date_text() drives the date layer; the "Date" +// complication uses the same formatter so it can live in any slot. +char *format_current_date(void) { //September 11, 2013 => 18 chars, 9 of which could potentially be dual byte utf8 characters //123456789012345678 @@ -657,60 +392,64 @@ void update_date_text() { "%y%m%e", // 248 YYMMdd }; char date_text[24]; - static char date_string[48]; + static char date_string[64]; // localized "%s %s %s" date; sized to avoid truncation under modern gcc // http://www.cplusplus.com/reference/ctime/strftime/ - if (settings.date_format < 195) { // localized date formats... + if (settings_get()->date_format < 195) { // localized date formats... char date_text_2[24]; - switch ( settings.date_format ) { + switch ( settings_get()->date_format ) { case 0: // MMMM DD, YYYY (localized) strftime(date_text, sizeof(date_text), "%d, %Y", currentTime); // DD, YYYY - snprintf(date_string, sizeof(date_string), "%s %s", lang_months.monthsNames[currentTime->tm_mon], date_text); // prefix Month + snprintf(date_string, sizeof(date_string), "%s %s", lang_months_get()->monthsNames[currentTime->tm_mon], date_text); // prefix Month break; case 1: // MMMM DD, 'YY (localized) strftime(date_text, sizeof(date_text), "%d, '%y", currentTime); // DD, 'YY - snprintf(date_string, sizeof(date_string), "%s %s", lang_months.monthsNames[currentTime->tm_mon], date_text); // prefix Month + snprintf(date_string, sizeof(date_string), "%s %s", lang_months_get()->monthsNames[currentTime->tm_mon], date_text); // prefix Month break; case 2: // Mmm DD, YYYY (localized) strftime(date_text, sizeof(date_text), "%d, %Y", currentTime); // DD, YYYY - snprintf(date_string, sizeof(date_string), "%s %s", lang_gen.abbrMonthsNames[currentTime->tm_mon], date_text); // prefix Mon + snprintf(date_string, sizeof(date_string), "%s %s", lang_gen_get()->abbrMonthsNames[currentTime->tm_mon], date_text); // prefix Mon break; case 3: // Mmm DD, 'YY (localized) strftime(date_text, sizeof(date_text), "%d, '%y", currentTime); // DD, 'YY - snprintf(date_string, sizeof(date_string), "%s %s", lang_gen.abbrMonthsNames[currentTime->tm_mon], date_text); // prefix Mon + snprintf(date_string, sizeof(date_string), "%s %s", lang_gen_get()->abbrMonthsNames[currentTime->tm_mon], date_text); // prefix Mon break; case 11: // D MMMM YYYY (localized) strftime(date_text, sizeof(date_text), "%d", currentTime); // D strftime(date_text_2, sizeof(date_text_2), "%Y", currentTime); // YYYY - snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_months.monthsNames[currentTime->tm_mon], date_text_2); // insert Month + snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_months_get()->monthsNames[currentTime->tm_mon], date_text_2); // insert Month break; case 12: // D MMMM 'YY (localized) strftime(date_text, sizeof(date_text), "%d", currentTime); // D strftime(date_text_2, sizeof(date_text_2), "'%y", currentTime); // YY - snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_months.monthsNames[currentTime->tm_mon], date_text_2); // insert Month + snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_months_get()->monthsNames[currentTime->tm_mon], date_text_2); // insert Month break; case 13: // D Mmm YYYY (localized) strftime(date_text, sizeof(date_text), "%d", currentTime); // D strftime(date_text_2, sizeof(date_text_2), "%Y", currentTime); // YYYY - snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_gen.abbrMonthsNames[currentTime->tm_mon], date_text_2); // insert Mon + snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_gen_get()->abbrMonthsNames[currentTime->tm_mon], date_text_2); // insert Mon break; case 14: // D Mmm 'YY (localized) strftime(date_text, sizeof(date_text), "%d", currentTime); // D strftime(date_text_2, sizeof(date_text_2), "'%y", currentTime); // YY - snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_gen.abbrMonthsNames[currentTime->tm_mon], date_text_2); // insert Mon + snprintf(date_string, sizeof(date_string), "%s %s %s", date_text, lang_gen_get()->abbrMonthsNames[currentTime->tm_mon], date_text_2); // insert Mon break; } } else { // non-localized date formats, straight strftime function calls - if ((settings.date_format>=195)||(settings.date_format<=254)) { // load from table - strftime(date_text, sizeof(date_text), datestr[settings.date_format-195], currentTime); - } else if (settings.date_format==255) { - strftime(date_text, sizeof(date_text), settings.strftime_format, currentTime); + if ((settings_get()->date_format>=195)||(settings_get()->date_format<=254)) { // load from table + strftime(date_text, sizeof(date_text), datestr[settings_get()->date_format-195], currentTime); + } else if (settings_get()->date_format==255) { + strftime(date_text, sizeof(date_text), adv_settings_get()->custom_date_fmt, currentTime); } snprintf(date_string, sizeof(date_string), "%s", date_text); // straight copy } - text_layer_set_text(date_layer, date_string); + return date_string; +} + +void update_date_text() { + apply_center(); // the date lives in the center row now (default left slot) } void update_time_text() { @@ -740,20 +479,20 @@ void update_time_text() { } void update_day_text(TextLayer *which_layer) { - text_layer_set_text(which_layer, lang_days.DaysOfWeek[currentTime->tm_wday]); + text_layer_set_text(which_layer, lang_days_get()->DaysOfWeek[currentTime->tm_wday]); } void update_month_text(TextLayer *which_layer) { - text_layer_set_text(which_layer, lang_months.monthsNames[currentTime->tm_mon]); + text_layer_set_text(which_layer, lang_months_get()->monthsNames[currentTime->tm_mon]); } void update_week_text(TextLayer *which_layer) { static char week_text[] = "W00"; char week_format[] = "W%V"; // V = ISO 8601 week number (00-53) - if (settings.week_format == 1) { + if (settings_get()->week_format == 1) { // U = Week number with the first Sunday as the first day of week one (00-53) week_format[2] = 'U'; - } else if (settings.week_format == 2) { + } else if (settings_get()->week_format == 2) { // W = Week number with the first Monday as the first day of week one (00-53) week_format[2] = 'W'; } @@ -763,9 +502,9 @@ void update_week_text(TextLayer *which_layer) { void update_ampm_text(TextLayer *which_layer) { if (currentTime->tm_hour < 12 ) { - text_layer_set_text(which_layer, lang_gen.abbrTime[0]); // 0-11 AM + text_layer_set_text(which_layer, lang_gen_get()->abbrTime[0]); // 0-11 AM } else { - text_layer_set_text(which_layer, lang_gen.abbrTime[1]); // 12-23 PM + text_layer_set_text(which_layer, lang_gen_get()->abbrTime[1]); // 12-23 PM } } @@ -775,6 +514,101 @@ void update_seconds_text(TextLayer *which_layer) { text_layer_set_text(which_layer, seconds_text); } +void update_location_text(TextLayer *which_layer) { + // weather provider's location name (truncated to the slot width) + text_layer_set_text(which_layer, weather_state()->city); +} + +#ifndef PBL_PLATFORM_APLITE +// Minimal "[-]int[.frac]" parser (Pebble libc lacks atof). +static bool tl_parse_coord(const char *s, float *out) { + if (!s || !s[0]) return false; + int sign = 1; const char *p = s; + if (*p == '-') { sign = -1; p++; } else if (*p == '+') { p++; } + long ip = 0; float frac = 0.0f, scale = 0.1f; bool any = false; + while (*p >= '0' && *p <= '9') { ip = ip * 10 + (*p - '0'); p++; any = true; } + if (*p == '.') { p++; while (*p >= '0' && *p <= '9') { frac += (*p - '0') * scale; scale *= 0.1f; p++; any = true; } } + if (!any) return false; + *out = sign * (ip + frac); + return true; +} + +static void sun_time_text(TextLayer *layer, char *buf, bool want_sunset) { + float lat, lon, sr, ss; + if (currentTime && timezone_offset != TIMEZONE_UNINITIALIZED && + tl_parse_coord(adv_settings_get()->weather_lat, &lat) && + tl_parse_coord(adv_settings_get()->weather_lon, &lon)) { + sun_times(lat, lon, currentTime->tm_yday, -timezone_offset / 4.0f, &sr, &ss); + float h = want_sunset ? ss : sr; + int hh = (int)h, mm = (int)((h - hh) * 60 + 0.5f); + if (mm >= 60) { mm -= 60; hh++; } + hh = ((hh % 24) + 24) % 24; // wrap to 0-23 (also bounds the format width) + mm = ((mm % 60) + 60) % 60; // wrap to 0-59 + snprintf(buf, 16, "%d:%02d", hh, mm); + text_layer_set_text(layer, buf); + } else { + text_layer_set_text(layer, "--:--"); // no location yet + } +} +void update_sunrise_text(TextLayer *l) { static char b[16]; sun_time_text(l, b, false); } +void update_sunset_text(TextLayer *l) { static char b[16]; sun_time_text(l, b, true); } +#else +void update_sunrise_text(TextLayer *l) { text_layer_set_text(l, "--:--"); } +void update_sunset_text(TextLayer *l) { text_layer_set_text(l, "--:--"); } +#endif + +// Moon phase from the date only (Conway's approximation; no coords needed). +void update_moon_text(TextLayer *which_layer) { + static const char *PHASES[8] = { "New", "Wax cres", "1st qtr", "Wax gib", + "Full", "Wan gib", "Last qtr", "Wan cres" }; + if (!currentTime) { text_layer_set_text(which_layer, "Moon"); return; } + int y = currentTime->tm_year + 1900, m = currentTime->tm_mon + 1, d = currentTime->tm_mday; + int r = y % 100; r %= 19; if (r > 9) { r -= 19; } + r = ((r * 11) % 30) + m + d; if (m < 3) { r += 2; } + r -= (y < 2000) ? 4 : 8; + int age = ((r % 30) + 30) % 30; // 0..29 days into the lunation + int phase = (age < 2 || age >= 28) ? 0 : (age < 6 ? 1 : (age < 9 ? 2 : (age < 13 ? 3 : + (age < 17 ? 4 : (age < 20 ? 5 : (age < 24 ? 6 : 7)))))); + text_layer_set_text(which_layer, PHASES[phase]); +} + +// Second time zone: local time shifted to clock2_tz (UTC offset, whole hours). +void update_clock2_text(TextLayer *which_layer) { + static char buf[16]; + if (!currentTime || timezone_offset == TIMEZONE_UNINITIALIZED) { + text_layer_set_text(which_layer, "--:--"); + return; + } + int local_tz_min = -timezone_offset * 15; // local UTC offset in minutes + int utc_min = currentTime->tm_hour * 60 + currentTime->tm_min - local_tz_min; + int second_min = (((utc_min + adv_settings_get()->clock2_tz * 60) % 1440) + 1440) % 1440; + snprintf(buf, sizeof(buf), "%d:%02d", second_min / 60, second_min % 60); + text_layer_set_text(which_layer, buf); +} + +// System-info slots: the connection and the two batteries can be shown in any +// complication slot, so they share the same TextLayer renderer as the rest. +void update_wbatt_text(TextLayer *which_layer) { + static char buf[16]; + snprintf(buf, sizeof(buf), "%d%%", battery_percent); + text_layer_set_text(which_layer, buf); +} + +void update_pbatt_text(TextLayer *which_layer) { + static char buf[16]; + if (phone_battery_percent >= 0) { + snprintf(buf, sizeof(buf), "%d%%", phone_battery_percent); + } else { + snprintf(buf, sizeof(buf), "--"); + } + text_layer_set_text(which_layer, buf); +} + +void update_conn_text(TextLayer *which_layer) { + text_layer_set_text(which_layer, bluetooth_connected + ? lang_gen_get()->statuses[0] : lang_gen_get()->statuses[1]); +} + char * get_doy_text() { static char doy_text[] = "D000"; strftime(doy_text, sizeof(doy_text), "D%j", currentTime); @@ -783,12 +617,8 @@ char * get_doy_text() { char * get_dliy_text() { static char dliy_text[] = "R000"; - int daysThisFeb = daysInMonth(1, currentTime->tm_year + 1900); - int daysThisYear = 365; - if (daysThisFeb == 29) { daysThisYear = 366; } - int daysSinceJanFirst = currentTime->tm_yday; // 0-365 inclusive - int daysLeftThisYear = daysThisYear - daysSinceJanFirst - 1; - snprintf(dliy_text, sizeof(dliy_text), "R%03d", daysLeftThisYear); + int days_left = days_left_in_year(currentTime->tm_year + 1900, currentTime->tm_yday); + format_days_left_in_year(days_left, dliy_text, sizeof(dliy_text)); return dliy_text; } @@ -807,173 +637,259 @@ void update_doy_dliy_text(TextLayer *which_layer) { } void update_timezone_text(TextLayer *which_layer) { - static char timezone_text[10]; - int tz_hours = 0; - int tz_mins = 0; - tz_mins = timezone_offset % 4; - tz_hours = (timezone_offset - tz_mins)/ 4; - tz_mins = tz_mins * 15; - if (timezone_offset == TIMEZONE_UNINITIALIZED) { - snprintf(timezone_text, sizeof(timezone_text), "UTC ?"); - } else if (timezone_offset > 0) { - if (tz_mins == 0) { - //snprintf(timezone_text, sizeof(timezone_text), "UTC-%d:00", tz_hours); - snprintf(timezone_text, sizeof(timezone_text), "UTC-%d", tz_hours); - } else { - snprintf(timezone_text, sizeof(timezone_text), "UTC-%d:%d", tz_hours, tz_mins); - } - } else { - if (tz_mins == 0) { - //snprintf(timezone_text, sizeof(timezone_text), "UTC+%d:00", abs(tz_hours)); - snprintf(timezone_text, sizeof(timezone_text), "UTC+%d", abs(tz_hours)); - } else { - snprintf(timezone_text, sizeof(timezone_text), "UTC+%d:%d", abs(tz_hours), abs(tz_mins)); - } - } + static char timezone_text[16]; + format_timezone_offset(timezone_offset, timezone_text, sizeof(timezone_text)); text_layer_set_text(which_layer, timezone_text); } -void process_show_week() { - // LEFT - switch ( settings.show_week ) { - case 0: // Hide - //layer_set_hidden(text_layer_get_layer(week_layer), true); - return; - case 1: // Show Week - update_week_text(week_layer); - break; - case 2: // Show Timezone - update_timezone_text(week_layer); - break; - case 3: // Show AM/PM - update_ampm_text(week_layer); - break; - case 4: // Show Day of Year - update_doy_text(week_layer); - break; - case 5: // Show Days Left in Year - update_dliy_text(week_layer); - break; - case 6: // Show Seconds - update_seconds_text(week_layer); - break; +// All three below-time slots ("complications") share the same content menu. +// Keep these values in sync with src/js/config.js. +void update_slot_text(TextLayer *layer, uint8_t content) { + switch (content) { + case 1: update_day_text(layer); break; // Day name + case 2: update_month_text(layer); break; // Month name + case 3: update_week_text(layer); break; // Week number + case 4: update_timezone_text(layer); break; // Timezone + case 5: update_ampm_text(layer); break; // AM/PM + case 6: update_doy_text(layer); break; // Day of year + case 7: update_dliy_text(layer); break; // Days left in year + case 8: update_doy_dliy_text(layer); break; // Day of year / left (alternating) + case 9: update_seconds_text(layer); break; // Seconds + case 10: update_location_text(layer); break; // Weather location + case 11: update_sunrise_text(layer); break; // Sunrise + case 12: update_sunset_text(layer); break; // Sunset + case 13: update_moon_text(layer); break; // Moon phase + case 14: update_clock2_text(layer); break; // Second time zone + case 15: update_wbatt_text(layer); break; // Watch battery + case 16: update_pbatt_text(layer); break; // Phone battery + case 17: update_conn_text(layer); break; // Bluetooth connection + case 18: text_layer_set_text(layer, format_current_date()); break; // Date + default: break; // 0 = hidden } } -void process_show_day() { - // MIDDLE - switch ( settings.show_day ) { - case 0: // Hide - //layer_set_hidden(text_layer_get_layer(day_layer), true); - return; - case 1: // Show Day - update_day_text(day_layer); - break; - case 2: // Show Month - update_month_text(day_layer); - break; - case 3: // Show Timezone - update_timezone_text(day_layer); - break; - case 4: // Show Week - update_week_text(day_layer); - break; - case 5: // Show AM/PM - update_ampm_text(day_layer); - break; - case 6: // Show DoY/DLiY - update_doy_dliy_text(day_layer); - break; +// Lay out a two-slot complication row at (top,h): both set -> halves; one set -> +// full-width centered in the left layer; none -> both hidden. +static void layout_two_slots(TextLayer *l, TextLayer *r, uint8_t cl, uint8_t cr, int top, int h) { + int half = DEVICE_WIDTH / 2; + Layer *ll = text_layer_get_layer(l), *rl = text_layer_get_layer(r); + if (cl && cr) { + layer_set_frame(ll, GRect(2, top, half - 4, h)); text_layer_set_text_alignment(l, GTextAlignmentLeft); + layer_set_frame(rl, GRect(half + 2, top, half - 4, h)); text_layer_set_text_alignment(r, GTextAlignmentRight); + layer_set_hidden(ll, false); layer_set_hidden(rl, false); + update_slot_text(l, cl); update_slot_text(r, cr); + } else if (cl || cr) { + layer_set_frame(ll, GRect(2, top, DEVICE_WIDTH - 4, h)); text_layer_set_text_alignment(l, GTextAlignmentCenter); + layer_set_hidden(ll, false); layer_set_hidden(rl, true); + update_slot_text(l, cl ? cl : cr); + } else { + layer_set_hidden(ll, true); layer_set_hidden(rl, true); } } -void process_show_ampm() { - // RIGHT - switch ( settings.show_am_pm ) { - case 0: // Hide - //layer_set_hidden(text_layer_get_layer(ampm_layer), true); - return; - case 1: // Show AM/PM - update_ampm_text(ampm_layer); - break; - case 2: // Show Timezone - update_timezone_text(ampm_layer); - break; - case 3: // Show Week - update_week_text(ampm_layer); - break; - case 4: // Show Day of Year - update_doy_text(ampm_layer); - break; - case 5: // Show Days Left in Year - update_dliy_text(ampm_layer); - break; - case 6: // Show Seconds - update_seconds_text(ampm_layer); - break; +void apply_center(void) { + if (!date_layer || !ctr_r_layer) { return; } // not built yet + int voff = (strcmp(lang_gen_get()->language, "RU") == 0) + ? (showing_statusbar ? -4 : 0) : (showing_statusbar ? -9 : -5); + layout_two_slots(date_layer, ctr_r_layer, settings_get()->slot_ctr_l, settings_get()->slot_ctr_r, + REL_CLOCK_DATE_TOP + voff, REL_CLOCK_DATE_HEIGHT); +} + +void apply_bottom(void) { + if (!week_layer || !ampm_layer) { return; } + int voff = (strcmp(lang_gen_get()->language, "RU") == 0) ? -2 : 0; + layout_two_slots(week_layer, ampm_layer, settings_get()->show_week, settings_get()->show_am_pm, + REL_CLOCK_SUBTEXT_TOP + voff, 22); +} + +// The two status-bar slots also draw from the unified menu; battery/connection +// content additionally shows a 16px icon (the only contents with a glyph). +static GBitmap *stat_slot_icon(uint8_t content) { + switch (content) { + case 15: return image_watch_icon; // Watch battery + case 16: return image_phone_icon; // Phone battery + case 17: return image_bt16_icon; // Bluetooth + default: return NULL; // text-only complication } } -void position_connection_layer() { - static int connection_vert_offset = 0; - // potentially adjust the connection position, depending on language/font - if ( strcmp(lang_gen.language,"RU") == 0 ) { // Unicode font w/ Cyrillic characters - connection_vert_offset = 2; - } else { // Standard font - connection_vert_offset = 0; +static bool is_battery_content(uint8_t c) { return c == 15 || c == 16; } +static bool batt_style_is_bar(uint8_t s) { return s == 0 || s == 3; } // 0 bar, 3 bar+icon + +// Battery-bar box geometry for one side. with_icon (bar+icon style) reserves a +// leading 18px for the glyph on the outer edge and narrows the box. +static void batt_box_geom(bool is_right, bool with_icon, int *bx, int *bw) { + int half = DEVICE_WIDTH / 2; + if (is_right) { + *bx = half + 2; + *bw = with_icon ? half - 22 : half - 12; + } else { + *bx = with_icon ? 20 : 2; + *bw = with_icon ? half - 28 : half - 10; } - layer_set_frame( text_layer_get_layer(text_connection_layer), GRect(20+STAT_BT_ICON_LEFT, connection_vert_offset, 72, 22) ); } -void position_date_layer() { - static int date_vert_offset = 0; - // potentially adjust the date position, depending on language/font - if ( strcmp(lang_gen.language,"RU") == 0 ) { // Unicode font w/ Cyrillic characters - if (showing_statusbar) { - date_vert_offset = -4; +// Render one status-bar slot. Battery content honours batt_style: bar (outline +// drawn by battery_layer with the % centred inside), text-only, or icon+text. +// Other content shows its icon (if any) on the outer edge with the value beside. +static void apply_stat_slot(TextLayer *txt, BitmapLayer *icon, uint8_t content, bool is_right) { + int half = DEVICE_WIDTH / 2; + Layer *il = bitmap_layer_get_layer(icon); + Layer *tl = text_layer_get_layer(txt); + uint8_t bstyle = settings_get()->batt_style; + + if (is_battery_content(content) && batt_style_is_bar(bstyle)) { // BAR (+ optional icon) + bool with_icon = (bstyle == 3); + int bx, bw; batt_box_geom(is_right, with_icon, &bx, &bw); + if (with_icon) { + bitmap_layer_set_bitmap(icon, stat_slot_icon(content)); + layer_set_hidden(il, false); + layer_set_frame(il, GRect(is_right ? DEVICE_WIDTH - 18 : 2, 4, 16, 16)); } else { - date_vert_offset = 0; + layer_set_hidden(il, true); } - } else { // Standard font (EN, etc.) - if (showing_statusbar) { - date_vert_offset = -9; + text_layer_set_text_alignment(txt, GTextAlignmentCenter); + layer_set_frame(tl, GRect(bx, 0, bw, 20)); // % in the box, nudged up (was clipping low) + update_slot_text(txt, content); + return; + } + + GBitmap *bmp = (is_battery_content(content) && bstyle == 1) ? NULL : stat_slot_icon(content); + if (bmp) { // ICON + value + bitmap_layer_set_bitmap(icon, bmp); + layer_set_hidden(il, false); + if (is_right) { + layer_set_frame(il, GRect(DEVICE_WIDTH - 18, 4, 16, 16)); + text_layer_set_text_alignment(txt, GTextAlignmentRight); + layer_set_frame(tl, GRect(half, 2, DEVICE_WIDTH - 20 - half, 22)); } else { - date_vert_offset = -5; + layer_set_frame(il, GRect(2, 4, 16, 16)); + text_layer_set_text_alignment(txt, GTextAlignmentLeft); + layer_set_frame(tl, GRect(20, 2, half - 22, 22)); } + } else { // TEXT only + layer_set_hidden(il, true); + if (is_right) { + text_layer_set_text_alignment(txt, GTextAlignmentRight); + layer_set_frame(tl, GRect(half, 2, DEVICE_WIDTH - 2 - half, 22)); + } else { + text_layer_set_text_alignment(txt, GTextAlignmentLeft); + layer_set_frame(tl, GRect(2, 2, half - 4, 22)); + } + } + update_slot_text(txt, content); +} + +// A lone status complication is centred across the whole bar (the row is +// dynamic like the others: one slot = centre, two = halves). Rendered into the +// left layer/icon; the right slot is hidden by the caller. Bar style keeps its +// side box, so it is handled by the normal two-slot path instead. +static void apply_stat_single(uint8_t content) { + TextLayer *txt = text_connection_layer; + Layer *il = bitmap_layer_get_layer(bmp_phone_layer); + Layer *tl = text_layer_get_layer(txt); + GBitmap *bmp = (is_battery_content(content) && settings_get()->batt_style == 1) ? NULL : stat_slot_icon(content); + if (bmp) { // icon + value, group centred + int gx = DEVICE_WIDTH / 2 - 27; // ~half the icon+value group width + bitmap_layer_set_bitmap(bmp_phone_layer, bmp); + layer_set_hidden(il, false); + layer_set_frame(il, GRect(gx, 4, 16, 16)); + text_layer_set_text_alignment(txt, GTextAlignmentLeft); + layer_set_frame(tl, GRect(gx + 18, 2, DEVICE_WIDTH - (gx + 18) - 2, 22)); + } else { // text only, centred full width + layer_set_hidden(il, true); + text_layer_set_text_alignment(txt, GTextAlignmentCenter); + layer_set_frame(tl, GRect(2, 2, DEVICE_WIDTH - 4, 22)); + } + update_slot_text(txt, content); +} + +// Position the proportional fill (an invert effect, raised above the % text so +// the digits stay readable over the filled part) inside one battery box. +static void set_batt_fill(EffectLayer *fl, int box_x, int box_w, int pct, bool show) { + if (!fl) { return; } + Layer *l = effect_layer_get_layer(fl); + if (!show || pct < 0) { layer_set_hidden(l, true); return; } + if (pct > 100) { pct = 100; } + int fillw = (box_w - 2) * pct / 100; + if (fillw < 1 && pct > 0) { fillw = 1; } + // Full interior height (box is y4..20) so the fill has no empty top/bottom + // strip and covers the whole % text, keeping it readable when inverted. + layer_set_frame(l, GRect(box_x + 1, 5, fillw, 14)); + layer_set_hidden(l, false); + layer_add_child(statusbar, l); // raise to top so it inverts the box + % text +} + +// Re-apply both status-bar slots (content + icon + position). Cheap; call it +// whenever the underlying data (battery, connection) or the config changes. +void refresh_stat_slots(void) { + if (!text_connection_layer || !text_battery_layer) { return; } // not built yet + uint8_t cl = settings_get()->slot_stat_l, cr = settings_get()->slot_stat_r; + bool bar = batt_style_is_bar(settings_get()->batt_style); + bool with_icon = settings_get()->batt_style == 3; + // One slot set (and not the side-drawn bar style) -> centre it; else two halves. + bool single = (cl && !cr) || (!cl && cr); + if (single && !bar) { + apply_stat_single(cl ? cl : cr); + layer_set_hidden(text_layer_get_layer(text_battery_layer), true); + layer_set_hidden(bitmap_layer_get_layer(bmp_watch_layer), true); + } else { + apply_stat_slot(text_connection_layer, bmp_phone_layer, cl, false); + apply_stat_slot(text_battery_layer, bmp_watch_layer, cr, true); + } + + bool bars = bar && (is_battery_content(cl) || is_battery_content(cr)); + if (battery_layer) { // battery_layer draws the bar outlines + layer_set_hidden(battery_layer, !bars); + if (bars) { layer_mark_dirty(battery_layer); } } - layer_set_frame( text_layer_get_layer(date_layer), GRect(REL_CLOCK_DATE_LEFT, REL_CLOCK_DATE_TOP + date_vert_offset, REL_CLOCK_DATE_WIDTH, REL_CLOCK_DATE_HEIGHT) ); + int lx, lw, rx, rw; + batt_box_geom(false, with_icon, &lx, &lw); + batt_box_geom(true, with_icon, &rx, &rw); + set_batt_fill(battery_meter_layer, lx, lw, + is_battery_content(cl) ? (cl == 15 ? battery_percent : phone_battery_percent) : -1, + bar && is_battery_content(cl)); + set_batt_fill(batt_fill_r, rx, rw, + is_battery_content(cr) ? (cr == 15 ? battery_percent : phone_battery_percent) : -1, + bar && is_battery_content(cr)); +} + +void position_connection_layer() { + // Status-bar slots own their own geometry now; re-apply them (also picks up + // the current language/font via the value text). + refresh_stat_slots(); +} + +void position_date_layer() { + apply_center(); // center row owns the date band now } void position_day_layer() { - // potentially adjust the day position, depending on language/font - static int day_vert_offset = 0; - if ( strcmp(lang_gen.language,"RU") == 0 ) { // Unicode font w/ Cyrillic characters - day_vert_offset = -2; - } else { // Standard font - day_vert_offset = 0; + apply_bottom(); // bottom row owns the above-calendar band now +} + +// Clock font scales to the time band height; Roboto (wide) only on wide screens +// where it won't collide with the weather to its left. Returns a non-const +// FONT_KEY string literal so it can pass to set_layer_attr_sfont(char*). +char *time_font_key(void) { + switch (clock_font_for(DEVICE_WIDTH, REL_CLOCK_TIME_HEIGHT)) { + case CLOCK_FONT_ROBOTO_49: return FONT_KEY_ROBOTO_BOLD_SUBSET_49; + case CLOCK_FONT_LECO_42: return FONT_KEY_LECO_42_NUMBERS; + case CLOCK_FONT_LECO_38: return FONT_KEY_LECO_38_BOLD_NUMBERS; + case CLOCK_FONT_LECO_32: return FONT_KEY_LECO_32_BOLD_NUMBERS; + default: return FONT_KEY_LECO_28_LIGHT_NUMBERS; } - layer_set_frame( text_layer_get_layer(day_layer), GRect(REL_CLOCK_DATE_LEFT, REL_CLOCK_SUBTEXT_TOP + day_vert_offset, REL_CLOCK_DATE_WIDTH, REL_CLOCK_DATE_HEIGHT) ); } void position_time_layer() { - // potentially adjust the clock position, if we've added/removed the week, day, or AM/PM layers - static int time_offset = 0; - static int weather_offset = 0; - if (!settings.show_day && !settings.show_week && !settings.show_am_pm) { - time_offset = 12; - weather_offset = 0; - } else { - time_offset = 2; - weather_offset = -10; - } - layer_set_frame( text_layer_get_layer(time_layer), GRect(REL_CLOCK_TIME_LEFT, REL_CLOCK_TIME_TOP + time_offset, DEVICE_WIDTH, REL_CLOCK_TIME_HEIGHT) ); - layer_set_frame( weather_layer, GRect(REL_CLOCK_TIME_LEFT, weather_offset, DEVICE_WIDTH, LAYOUT_SLOT_HEIGHT) ); + // The clock and the weather both live in the time band; seat them on it so the + // weather is vertically centred against the time instead of floating above. + layer_set_frame( text_layer_get_layer(time_layer), GRect(REL_CLOCK_TIME_LEFT, REL_CLOCK_TIME_TOP, DEVICE_WIDTH, REL_CLOCK_TIME_HEIGHT) ); + weather_set_frame( GRect(REL_CLOCK_TIME_LEFT, REL_CLOCK_TIME_TOP, DEVICE_WIDTH, REL_CLOCK_TIME_HEIGHT) ); } void update_datetime_subtext() { - process_show_week(); - process_show_day(); - process_show_ampm(); + apply_bottom(); position_time_layer(); } @@ -987,11 +903,11 @@ void datetime_layer_update_callback(Layer *me, GContext* ctx) { } void statusbar_visible() { - if (adv_settings.showStatus == 0) { + if (adv_settings_get()->showStatus == 0) { showing_statusbar = false; - } else if (adv_settings.showStatus == 1) { + } else if (adv_settings_get()->showStatus == 1) { showing_statusbar = true; - } else if (battery_percent <= adv_settings.showStatusBat) { + } else if (battery_percent <= adv_settings_get()->showStatusBat) { showing_statusbar = true; } else { showing_statusbar = false; @@ -999,12 +915,12 @@ void statusbar_visible() { } void toggle_weather() { - if (adv_settings.weather_update) { + if (adv_settings_get()->weather_update) { //if (!showing_statusbar) { text_layer_set_text_alignment(date_layer, GTextAlignmentRight); } text_layer_set_text_alignment(time_layer, GTextAlignmentRight); - layer_set_hidden(weather_layer, false); + weather_set_hidden(false); } else { - layer_set_hidden(weather_layer, true); + weather_set_hidden(true); text_layer_set_text_alignment(time_layer, GTextAlignmentCenter); //text_layer_set_text_alignment(date_layer, GTextAlignmentCenter); } @@ -1014,29 +930,31 @@ void toggle_statusbar() { if (showing_statusbar) { // status layer_set_hidden(statusbar, false); - // date + // date / center-left (alignment owned by apply_center) layer_add_child(datetime_layer, text_layer_get_layer(date_layer)); - if (adv_settings.weather_update && (settings.show_day || settings.show_week || settings.show_am_pm)) { - text_layer_set_text_alignment(date_layer, GTextAlignmentRight); - } else { - text_layer_set_text_alignment(date_layer, GTextAlignmentCenter); - } // icon(s) layer_add_child(statusbar, bitmap_layer_get_layer(bmp_charging_layer)); - //layer_set_frame( bitmap_layer_get_layer(bmp_charging_layer), GRect(STAT_CHRG_ICON_LEFT, STAT_CHRG_ICON_TOP, 20, 20) ); layer_add_child(statusbar, battery_layer); - layer_add_child(statusbar, effect_layer_get_layer(battery_meter_layer)); + // Keep the slot value text above battery_layer (which draws the bar-style + // outline) so re-parenting here doesn't bury the percentage under the box. + // The battery fills are (re)stacked on top by refresh_stat_slots() afterwards. + if (text_connection_layer) { layer_add_child(statusbar, text_layer_get_layer(text_connection_layer)); } + if (text_battery_layer) { layer_add_child(statusbar, text_layer_get_layer(text_battery_layer)); } } else { // status layer_set_hidden(statusbar, true); - // date - layer_add_child(slot_status, text_layer_get_layer(date_layer)); - text_layer_set_text_alignment(date_layer, GTextAlignmentCenter); + // When the bar is only auto-hidden (when-low) the 24px strip is still + // reserved, so move the date up to use it; when the bar is fully disabled + // (showStatus 0) there is no strip, so keep the date in the center row to + // avoid clipping it to a zero-height parent. + if (adv_settings_get()->showStatus != 0) { + layer_add_child(slot_status, text_layer_get_layer(date_layer)); + } else { + layer_add_child(datetime_layer, text_layer_get_layer(date_layer)); + } // icon(s) layer_add_child(datetime_layer, bitmap_layer_get_layer(bmp_charging_layer)); - //layer_set_frame( bitmap_layer_get_layer(bmp_charging_layer), GRect(124, -2, 20, 20) ); layer_add_child(datetime_layer, battery_layer); - layer_add_child(datetime_layer, effect_layer_get_layer(battery_meter_layer)); } position_date_layer(); } @@ -1070,36 +988,52 @@ void slot_bot_layer_update_callback(Layer *me, GContext* ctx) { // TODO: configurable: draw appropriate slot } +// Draw a battery outline + nib for the "bar with %" style; the percentage text +// is the slot's own TextLayer, centred inside this box. +static void draw_batt_box(GContext *ctx, int x, int w, bool low, bool nib_left) { + graphics_context_set_stroke_color(ctx, low ? theme_palette().warn : theme_palette().fg); + graphics_draw_rect(ctx, GRect(x, 4, w, 16)); + // The left slot's nib faces left (toward its icon) so the two read as one unit. + graphics_draw_rect(ctx, GRect(nib_left ? x - 2 : x + w, 4 + 5, 2, 6)); +} + void battery_layer_update_callback(Layer *me, GContext* ctx) { -// simply draw the battery outline here - the text is a different layer, and we then 'fill' it with an inverterLayer + (void)me; + if (!batt_style_is_bar(settings_get()->batt_style)) { return; } // only bar styles draw a box setColors(ctx); -// battery outline - graphics_draw_rect(ctx, GRect(STAT_BATT_LEFT, STAT_BATT_TOP, STAT_BATT_WIDTH, STAT_BATT_HEIGHT)); -// battery 'nib' terminal - graphics_draw_rect(ctx, GRect(STAT_BATT_LEFT + STAT_BATT_WIDTH - 1, - STAT_BATT_TOP + (STAT_BATT_HEIGHT - STAT_BATT_NIB_HEIGHT)/2, - STAT_BATT_NIB_WIDTH, - STAT_BATT_NIB_HEIGHT)); + bool with_icon = settings_get()->batt_style == 3; + uint8_t cl = settings_get()->slot_stat_l, cr = settings_get()->slot_stat_r; + int bx, bw; + if (is_battery_content(cl)) { + int pct = (cl == 15) ? battery_percent : phone_battery_percent; + batt_box_geom(false, with_icon, &bx, &bw); + draw_batt_box(ctx, bx, bw, pct >= 0 && pct <= 20, true); // left: nib toward the icon + } + if (is_battery_content(cr)) { + int pct = (cr == 15) ? battery_percent : phone_battery_percent; + batt_box_geom(true, with_icon, &bx, &bw); + draw_batt_box(ctx, bx, bw, pct >= 0 && pct <= 20, false); // right: nib on the outer edge + } } static void request_weather(void *data) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Requesting Weather [%d/%d]", weather.failures, weather.requests); } - strncpy(weather.condition, "h", sizeof(weather.condition)-1); // h = updating 'cloud' icon - layer_mark_dirty(weather_layer); // update UI element to indicate we're fetching weather... + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Requesting Weather [%d/%d]", weather_state()->failures, weather_state()->requests); } + weather_state()->condition[0] = 'h'; weather_state()->condition[1] = '\0'; // h = updating 'cloud' icon + weather_mark_dirty(); // update UI element to indicate we're fetching weather... DictionaryIterator *iter; AppMessageResult result = app_message_outbox_begin(&iter); if (iter == NULL) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } return; } if (dict_write_uint8(iter, AK_MESSAGE_TYPE, AK_REQUEST_WEATHER) != DICT_OK) { return; } - if (dict_write_uint8(iter, AK_WEATHER_FMT, adv_settings.weather_format) != DICT_OK) { + if (dict_write_uint8(iter, AK_WEATHER_FMT, adv_settings_get()->weather_format) != DICT_OK) { return; } app_message_outbox_send(); - weather.requests++; + weather_state()->requests++; weather_request = NULL; } @@ -1107,7 +1041,7 @@ static void request_timezone(void *data) { DictionaryIterator *iter; AppMessageResult result = app_message_outbox_begin(&iter); if (iter == NULL) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } return; } if (dict_write_uint8(iter, AK_MESSAGE_TYPE, AK_TIMEZONE_OFFSET) != DICT_OK) { @@ -1123,19 +1057,19 @@ static void watch_version_send(void *data) { AppMessageResult result = app_message_outbox_begin(&iter); if (iter == NULL) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } return; } if (result != APP_MSG_OK) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Dict write failed to open outbox: %d", (AppMessageResult) result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Dict write failed to open outbox: %d", (AppMessageResult) result); } return; } if (dict_write_uint8(iter, AK_MESSAGE_TYPE, AK_SEND_WATCH_VERSION) != DICT_OK) { return; } - if (dict_write_uint8(iter, AK_SEND_WATCH_VERSION, settings.version) != DICT_OK) { + if (dict_write_uint8(iter, AK_SEND_WATCH_VERSION, settings_get()->version) != DICT_OK) { return; } if (dict_write_cstring(iter, AK_SEND_CONFIG_VERSION, CONFIG_VERSION) != DICT_OK) { @@ -1148,13 +1082,13 @@ static void battery_status_send(void *data) { static uint8_t sent_battery_percent = 10; static bool sent_battery_charging = false; static bool sent_battery_plugged = false; - if (!settings.track_battery) { + if (!settings_get()->track_battery) { return; // if user has chosen not to track battery (saves power w/ appmessages) } if ( (battery_percent == sent_battery_percent ) & (battery_charging == sent_battery_charging ) & (battery_plugged == sent_battery_plugged ) ) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "repeat battery reading"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "repeat battery reading"); } battery_sending = NULL; return; // no need to resend the same value } @@ -1163,12 +1097,12 @@ static void battery_status_send(void *data) { AppMessageResult result = app_message_outbox_begin(&iter); if (iter == NULL) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "iterator is null: %d", result); } return; } if (result != APP_MSG_OK) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Dict write failed to open outbox: %d", (AppMessageResult) result); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Dict write failed to open outbox: %d", (AppMessageResult) result); } return; } @@ -1193,64 +1127,46 @@ static void battery_status_send(void *data) { void set_status_charging_icon() { // this icon shows either DND, hourly vibration, or charging... + bool chrg_shown = true; if (battery_charging) { // charging - layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), false); bitmap_layer_set_bitmap(bmp_charging_layer, image_charging_icon); - } else { // not charging - if (dnd_period_active) { - layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), false); - bitmap_layer_set_bitmap(bmp_charging_layer, image_dnd_icon); - } else { - if (battery_plugged) { // plugged but not charging = charging complete... - layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), true); - } else { // normal wear - if (settings.vibe_hour && vibe_period_active) { - layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), false); - bitmap_layer_set_bitmap(bmp_charging_layer, image_hourvibe_icon); - } else { - layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), true); - } - } - } + } else if (dnd_period_active) { + bitmap_layer_set_bitmap(bmp_charging_layer, image_dnd_icon); + } else if (!battery_plugged && settings_get()->vibe_hour && vibe_period_active) { + bitmap_layer_set_bitmap(bmp_charging_layer, image_hourvibe_icon); + } else { // plugged-but-full, or normal wear: nothing to show + chrg_shown = false; } + layer_set_hidden(bitmap_layer_get_layer(bmp_charging_layer), !chrg_shown); } static void toggle_slot_bottom(void *data) { watch_version_send(NULL); // no guarantee the JS is there to receive me... - static Layer* last = NULL; - Layer* which = (Layer*)data; - if (last != NULL) { layer_set_hidden(last, true); } // hide visible layer - last = which; // we're about to show a layer, mark it as the visible layer - if (last != NULL) { layer_set_hidden(last, false); } // show new visible layer + static int shown = -1; // 0 = splash, 1 = calendar + int which = (int)(intptr_t)data; + if (shown == 0) { splash_set_hidden(true); } else if (shown == 1) { calendar_set_hidden(true); } + shown = which; + if (shown == 0) { splash_set_hidden(false); } else if (shown == 1) { calendar_set_hidden(false); } bottom_toggle = NULL; } static void handle_battery(BatteryChargeState charge_state) { - static char battery_text[] = "100"; - battery_percent = charge_state.charge_percent; - uint8_t battery_meter = battery_percent/10*(STAT_BATT_WIDTH-4)/10; battery_charging = charge_state.is_charging; battery_plugged = charge_state.is_plugged; - // fill it in with current power - layer_set_frame(effect_layer_get_layer(battery_meter_layer), GRect(STAT_BATT_LEFT+2, STAT_BATT_TOP+2, battery_meter, STAT_BATT_HEIGHT-4)); - layer_set_hidden(effect_layer_get_layer(battery_meter_layer), false); - - //if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "battery reading"); } + //if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "battery reading"); } if (battery_sending == NULL) { // multiple battery events can fire in rapid succession, we'll let it settle down before logging it battery_sending = app_timer_register(5000, &battery_status_send, NULL); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "battery timer queued"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "battery timer queued"); } } set_status_charging_icon(); - snprintf(battery_text, sizeof(battery_text), "%d", charge_state.charge_percent); - text_layer_set_text(text_battery_layer, battery_text); - layer_mark_dirty(battery_layer); statusbar_visible(); toggle_statusbar(); + refresh_stat_slots(); // after toggle so the battery fills end up on top handle_vibe_suppression(); } @@ -1271,6 +1187,7 @@ void generate_vibe(uint32_t vibe_pattern_number) { .durations = (uint32_t []) {200, 100, 200, 100, 200}, .num_segments = 5 } ); + break; case 4: // Long vibes_long_pulse(); break; @@ -1297,15 +1214,16 @@ void generate_vibe(uint32_t vibe_pattern_number) { } } +// Connection/battery now live in the configurable status-bar slots; refreshing +// them re-renders whatever the user put there. +void set_connection_text(void) { + refresh_stat_slots(); +} + void update_connection() { - text_layer_set_text(text_connection_layer, bluetooth_connected ? lang_gen.statuses[0] : lang_gen.statuses[1]) ; - if (bluetooth_connected) { - generate_vibe(settings.vibe_pat_connect); // non-op, by default - bitmap_layer_set_bitmap(bmp_connection_layer, image_connection_icon); - } else { - generate_vibe(settings.vibe_pat_disconnect); // because, this is bad... - bitmap_layer_set_bitmap(bmp_connection_layer, image_noconnection_icon); - } + generate_vibe(bluetooth_connected ? settings_get()->vibe_pat_connect + : settings_get()->vibe_pat_disconnect); + refresh_stat_slots(); } static void handle_bluetooth(bool connected) { @@ -1315,14 +1233,53 @@ static void handle_bluetooth(bool connected) { if (bluetooth_connected == true) { if ( (timezone_request == NULL) & (timezone_offset == TIMEZONE_UNINITIALIZED) ) { timezone_request = app_timer_register(5000, &request_timezone, NULL); // give it time to settle... - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "timezone request timer queued"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "timezone request timer queued"); } } } } } +#ifdef PBL_COLOR +// Recolor an alpha icon's opaque pixels to `color` (keeps the antialiased edges), +// so status icons follow the theme like the text. Color platforms only. +static void tint_icon(GBitmap *bmp, GColor color) { + if (!bmp || gbitmap_get_format(bmp) != GBitmapFormat8Bit) { return; } + GRect b = gbitmap_get_bounds(bmp); + for (int y = b.origin.y; y < b.origin.y + b.size.h; y++) { + GBitmapDataRowInfo ri = gbitmap_get_data_row_info(bmp, y); + for (int x = ri.min_x; x <= ri.max_x; x++) { + GColor8 *px = (GColor8 *)&ri.data[x]; + if (px->a != 0) { px->r = color.r; px->g = color.g; px->b = color.b; } + } + } +} +#endif + +static void apply_palette(void) { + GColor fg = theme_palette().fg; + text_layer_set_text_color(time_layer, fg); + text_layer_set_text_color(date_layer, fg); + if (ctr_r_layer) { text_layer_set_text_color(ctr_r_layer, fg); } + text_layer_set_text_color(day_layer, fg); + text_layer_set_text_color(week_layer, fg); + text_layer_set_text_color(ampm_layer, fg); + text_layer_set_text_color(text_connection_layer, fg); + text_layer_set_text_color(text_battery_layer, fg); +#ifdef PBL_COLOR + tint_icon(image_charging_icon, fg); + tint_icon(image_hourvibe_icon, fg); + tint_icon(image_dnd_icon, fg); + tint_icon(image_phone_icon, fg); + tint_icon(image_watch_icon, fg); + tint_icon(image_bt16_icon, fg); + if (bmp_charging_layer) { layer_mark_dirty(bitmap_layer_get_layer(bmp_charging_layer)); } + if (bmp_phone_layer) { layer_mark_dirty(bitmap_layer_get_layer(bmp_phone_layer)); } + if (bmp_watch_layer) { layer_mark_dirty(bitmap_layer_get_layer(bmp_watch_layer)); } +#endif +} + static void set_unifont() { - if ( strcmp(lang_gen.language,"RU") == 0 ) { // Unicode font w/ Cyrillic characters + if ( strcmp(lang_gen_get()->language,"RU") == 0 ) { // Unicode font w/ Cyrillic characters // set fonts... text_layer_set_font(day_layer,unifont_16); text_layer_set_font(text_connection_layer, unifont_16); @@ -1349,39 +1306,45 @@ static void set_unifont() { bool period_check(uint8_t start_incr, uint8_t stop_incr, bool retval_on_equal) { // takes two periods (uint8_t 0-144) in 10 minute increments, and returns whether the current time falls inside them. // periods are fully inclusive, presently - if (start_incr == stop_incr) { return retval_on_equal; } - bool inside_period = false; uint8_t current_min_incr = (currentTime->tm_min - (currentTime->tm_min%10))/10; uint8_t current_incr = currentTime->tm_hour * 6 + current_min_incr; - if (start_incr > stop_incr) { // period crosses midnight of the day - if (current_incr >= start_incr || current_incr <= stop_incr) { - inside_period = true; - } - } else { // period occurs within a single day - if (current_incr >= start_incr && current_incr <= stop_incr) { - inside_period = true; - } - } - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Period Check... %d <= %d <= %d == %d*", start_incr, current_incr, stop_incr, (int)inside_period); } + bool inside_period = period_contains(start_incr, stop_incr, current_incr, retval_on_equal); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Period Check... %d <= %d <= %d == %d*", start_incr, current_incr, stop_incr, (int)inside_period); } return inside_period; } bool dnd_period_check() { - // TODO - adv_settings.DND_accel_off = 0, // Do Not Disturb: disable accelerometer polling during DND? - dnd_period_active = period_check(adv_settings.DND_start, adv_settings.DND_stop, false); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Tested DND period... %d", (int)dnd_period_active); } + // dnd_mode: 0 off, 1 follow the watch's Quiet Time, 2 use the app's own window. + uint8_t mode = adv_settings_get()->dnd_mode; + if (mode == 1) { + dnd_period_active = quiet_time_is_active(); + } else if (mode == 2) { + dnd_period_active = period_check(adv_settings_get()->DND_start, adv_settings_get()->DND_stop, false); + } else { + dnd_period_active = false; + } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Tested DND period... %d", (int)dnd_period_active); } return dnd_period_active; } bool hourvibe_period_check() { - // TODO - adv_settings.vibe_hour_days = 0, // Hour Vibe: days active [Su 1, Mo 2, Tu 4, We 8, Th 16, Fr 32, Sa 64 => 0 => 127] - vibe_period_active = period_check(adv_settings.vibe_hour_start, adv_settings.vibe_hour_stop, true); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Tested vibe period... %d", (int)vibe_period_active); } + // vibe_hour_days is the hourly-vibe mode: 0 off, 1 always, 2 window, 3 follow DND. + uint8_t mode = adv_settings_get()->vibe_hour_days; + if (mode == 1) { + vibe_period_active = true; + } else if (mode == 2) { + vibe_period_active = period_check(adv_settings_get()->vibe_hour_start, adv_settings_get()->vibe_hour_stop, true); + } else if (mode == 3) { + vibe_period_active = !dnd_period_check(); // active every hour except during Do Not Disturb + } else { + vibe_period_active = false; + } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Tested vibe period... %d", (int)vibe_period_active); } return vibe_period_active; } void set_layer_attr(TextLayer *textlayer, GTextAlignment Alignment) { text_layer_set_text_alignment(textlayer, Alignment); - text_layer_set_text_color(textlayer, GColorWhite); + text_layer_set_text_color(textlayer, theme_palette().fg); text_layer_set_background_color(textlayer, GColorClear); } @@ -1399,12 +1362,17 @@ static void window_load(Window *window) { unifont_16 = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_UNICODE_16)); unifont_16_bold = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_UNICODE_BOLD_16)); - climacons = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_CLIMACONS_32)); + // Narrow screens use a slightly smaller weather glyph so it balances the + // compact clock/temperature; wide screens (emery) keep the larger one. + climacons = fonts_load_custom_font(resource_get_handle( + (DEVICE_WIDTH >= 180) ? RESOURCE_ID_FONT_CLIMACONS_40 : RESOURCE_ID_FONT_CLIMACONS_28)); + weather_set_compact(DEVICE_WIDTH < 180); cal_normal = unifont_16; cal_bold = unifont_16_bold; Layer *window_layer = window_get_root_layer(window); GRect bounds = layer_get_bounds(window_layer); + compute_layout(bounds.size.w, bounds.size.h); slot_status = layer_create(GRect(0,LAYOUT_STAT,DEVICE_WIDTH,LAYOUT_SLOT_TOP)); //slot_status = layer_create(GRect(0,0,DEVICE_WIDTH,DEVICE_HEIGHT)); @@ -1421,116 +1389,128 @@ static void window_load(Window *window) { layer_add_child(window_layer, slot_top); GRect slot_top_bounds = layer_get_bounds(slot_top); - slot_bot = layer_create(GRect(0,LAYOUT_SLOT_BOT,DEVICE_WIDTH,LAYOUT_SLOT_HEIGHT)); + slot_bot = layer_create(GRect(0,LAYOUT_SLOT_BOT,DEVICE_WIDTH,LAYOUT_SLOT_BOT_HEIGHT)); layer_set_update_proc(slot_bot, slot_bot_layer_update_callback); layer_add_child(window_layer, slot_bot); GRect slot_bot_bounds = layer_get_bounds(slot_bot); - bmp_connection_layer = bitmap_layer_create( GRect(STAT_BT_ICON_LEFT, STAT_BT_ICON_TOP, 20, 20) ); - layer_add_child(statusbar, bitmap_layer_get_layer(bmp_connection_layer)); - image_connection_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_BT_LINKED_ICON); - image_noconnection_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_BT_NOLINK_ICON); - bmp_charging_layer = bitmap_layer_create( GRect(STAT_CHRG_ICON_LEFT, STAT_CHRG_ICON_TOP, 20, 20) ); + bitmap_layer_set_compositing_mode(bmp_charging_layer, GCompOpSet); layer_add_child(statusbar, bitmap_layer_get_layer(bmp_charging_layer)); image_charging_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_CHARGING_ICON); image_hourvibe_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_HOURVIBE_ICON); image_dnd_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_DONOTDISTURB_ICON); + // Status-bar slot icons (16px): one per slot, bitmap chosen by slot content. + image_phone_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_PHONE_ICON); + image_watch_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_WATCH_ICON); + image_bt16_icon = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_BT_16_ICON); + + bmp_phone_layer = bitmap_layer_create( GRect(2, 4, 16, 16) ); // left slot icon + bitmap_layer_set_compositing_mode(bmp_phone_layer, GCompOpSet); + layer_add_child(statusbar, bitmap_layer_get_layer(bmp_phone_layer)); + + bmp_watch_layer = bitmap_layer_create( GRect(DEVICE_WIDTH - 18, 4, 16, 16) ); // right slot icon + bitmap_layer_set_compositing_mode(bmp_watch_layer, GCompOpSet); + layer_add_child(statusbar, bitmap_layer_get_layer(bmp_watch_layer)); + dnd_period_check(); hourvibe_period_check(); set_status_charging_icon(); + // Graphical battery meter retired: battery now shows as a slot (text + icon). battery_layer = layer_create(stat_bounds); layer_set_update_proc(battery_layer, battery_layer_update_callback); layer_add_child(statusbar, battery_layer); + layer_set_hidden(battery_layer, true); datetime_layer = layer_create(slot_top_bounds); layer_set_update_proc(datetime_layer, datetime_layer_update_callback); layer_add_child(slot_top, datetime_layer); - calendar_layer = layer_create(slot_bot_bounds); - layer_set_update_proc(calendar_layer, calendar_layer_update_callback); - layer_add_child(slot_bot, calendar_layer); - layer_set_hidden(calendar_layer, true); + calendar_create(slot_bot, slot_bot_bounds); - splash_layer = layer_create(slot_bot_bounds); - layer_set_update_proc(splash_layer, splash_layer_update_callback); - layer_add_child(slot_bot, splash_layer); + splash_create(slot_bot, slot_bot_bounds); - toggle_slot_bottom((void*)splash_layer); // show @ start... - bottom_toggle = app_timer_register(2000, &toggle_slot_bottom, (void*)calendar_layer); // queue calendar to reappear in 2 seconds + toggle_slot_bottom((void*)(intptr_t)0); // show @ start... + bottom_toggle = app_timer_register(2000, &toggle_slot_bottom, (void*)(intptr_t)1); // queue calendar to reappear in 2 seconds - date_layer = text_layer_create( GRect(REL_CLOCK_DATE_LEFT, REL_CLOCK_DATE_TOP, REL_CLOCK_DATE_WIDTH, REL_CLOCK_DATE_HEIGHT) ); // see position_date_layer() + // Center row (above time): date_layer is the left slot, ctr_r_layer the right. + date_layer = text_layer_create( GRect(REL_CLOCK_DATE_LEFT, REL_CLOCK_DATE_TOP, REL_CLOCK_DATE_WIDTH, REL_CLOCK_DATE_HEIGHT) ); set_layer_attr_sfont(date_layer, FONT_KEY_GOTHIC_24, GTextAlignmentCenter); - position_date_layer(); // depends on font/language - update_date_text(); layer_add_child(datetime_layer, text_layer_get_layer(date_layer)); + ctr_r_layer = text_layer_create( GRect(DEVICE_WIDTH/2 + 2, REL_CLOCK_DATE_TOP, DEVICE_WIDTH/2 - 4, REL_CLOCK_DATE_HEIGHT) ); + set_layer_attr_sfont(ctr_r_layer, FONT_KEY_GOTHIC_24, GTextAlignmentRight); + layer_add_child(datetime_layer, text_layer_get_layer(ctr_r_layer)); + apply_center(); // position + fill both center slots - weather_layer = layer_create(slot_top_bounds); - layer_set_update_proc(weather_layer, weather_layer_update_callback); - layer_add_child(datetime_layer, weather_layer); + weather_create(datetime_layer, slot_top_bounds); time_layer = text_layer_create( GRect(REL_CLOCK_TIME_LEFT, REL_CLOCK_TIME_TOP, DEVICE_WIDTH - 2, REL_CLOCK_TIME_HEIGHT) ); // see position_time_layer() - set_layer_attr_cfont(time_layer, RESOURCE_ID_FONT_FUTURA_CONDENSED_48, GTextAlignmentCenter); + set_layer_attr_sfont(time_layer, time_font_key(), GTextAlignmentCenter); toggle_weather(); position_time_layer(); // make use of our whitespace, if we have it... update_time_text(); layer_add_child(datetime_layer, text_layer_get_layer(time_layer)); - week_layer = text_layer_create( GRect(4, REL_CLOCK_SUBTEXT_TOP, 140, 18) ); - set_layer_attr_sfont(week_layer, FONT_KEY_GOTHIC_14, GTextAlignmentLeft); + week_layer = text_layer_create( GRect(2, REL_CLOCK_SUBTEXT_TOP, DEVICE_WIDTH / 2 - 4, 22) ); // left half + set_layer_attr_sfont(week_layer, FONT_KEY_GOTHIC_18, GTextAlignmentLeft); layer_add_child(datetime_layer, text_layer_get_layer(week_layer)); - if ( settings.show_week == 0 ) { + if ( settings_get()->show_week == 0 ) { layer_set_hidden(text_layer_get_layer(week_layer), true); } - day_layer = text_layer_create( GRect(4, REL_CLOCK_SUBTEXT_TOP, REL_CLOCK_DATE_WIDTH, 18) ); // see position_day_layer() - set_layer_attr_sfont(day_layer, FONT_KEY_GOTHIC_14, GTextAlignmentCenter); - position_day_layer(); // depends on font/language + // Middle slot retired: only two complications above the calendar (left/right). + day_layer = text_layer_create( GRect(4, REL_CLOCK_SUBTEXT_TOP, REL_CLOCK_DATE_WIDTH, 22) ); + set_layer_attr_sfont(day_layer, FONT_KEY_GOTHIC_18, GTextAlignmentCenter); layer_add_child(datetime_layer, text_layer_get_layer(day_layer)); - if ( settings.show_day == 0 ) { - layer_set_hidden(text_layer_get_layer(day_layer), true); - } + layer_set_hidden(text_layer_get_layer(day_layer), true); - ampm_layer = text_layer_create( GRect(0, REL_CLOCK_SUBTEXT_TOP, 140, 18) ); - set_layer_attr_sfont(ampm_layer, FONT_KEY_GOTHIC_14, GTextAlignmentRight); + ampm_layer = text_layer_create( GRect(DEVICE_WIDTH / 2 + 2, REL_CLOCK_SUBTEXT_TOP, DEVICE_WIDTH / 2 - 4, 22) ); // right half + set_layer_attr_sfont(ampm_layer, FONT_KEY_GOTHIC_18, GTextAlignmentRight); layer_add_child(datetime_layer, text_layer_get_layer(ampm_layer)); - if ( settings.show_am_pm == 0 ) { + if ( settings_get()->show_am_pm == 0 ) { layer_set_hidden(text_layer_get_layer(ampm_layer), true); } update_datetime_subtext(); - text_connection_layer = text_layer_create( GRect(20+STAT_BT_ICON_LEFT, 0, 72, 22) ); // see position_connection_layer() + // Status-bar slots: left (value, left-aligned) and right (value, right-aligned); + // apply_stat_slot() sets their frames based on whether the content has an icon. + text_connection_layer = text_layer_create( GRect(20, 2, DEVICE_WIDTH/2 - 22, 22) ); // left slot set_layer_attr_sfont(text_connection_layer, FONT_KEY_GOTHIC_18, GTextAlignmentLeft); - update_connection(); - position_connection_layer(); // depends on font/language layer_add_child(statusbar, text_layer_get_layer(text_connection_layer)); - text_battery_layer = text_layer_create( GRect(STAT_BATT_LEFT, STAT_BATT_TOP-2, STAT_BATT_WIDTH, STAT_BATT_HEIGHT) ); - set_layer_attr_sfont(text_battery_layer, FONT_KEY_GOTHIC_14, GTextAlignmentCenter); - text_layer_set_text(text_battery_layer, "-"); - + text_battery_layer = text_layer_create( GRect(DEVICE_WIDTH/2, 2, DEVICE_WIDTH/2 - 20, 22) ); // right slot + set_layer_attr_sfont(text_battery_layer, FONT_KEY_GOTHIC_18, GTextAlignmentRight); layer_add_child(statusbar, text_layer_get_layer(text_battery_layer)); + refresh_stat_slots(); // fill both slots (content + icon + position) + set_unifont(); + apply_palette(); // NOTE: No more adding layers below here - the inverter layers NEED to be the last to be on top! - // hide battery meter, until we can fix the size/position later when subscribing + // Battery bar fills (invert effect): one per status battery slot, positioned + // proportionally by refresh_stat_slots(); hidden unless the bar style is used. battery_meter_layer = effect_layer_create(stat_bounds); effect_layer_add_effect(battery_meter_layer, effect_invert, NULL); layer_set_hidden(effect_layer_get_layer(battery_meter_layer), true); layer_add_child(statusbar, effect_layer_get_layer(battery_meter_layer)); + batt_fill_r = effect_layer_create(stat_bounds); + effect_layer_add_effect(batt_fill_r, effect_invert, NULL); + layer_set_hidden(effect_layer_get_layer(batt_fill_r), true); + layer_add_child(statusbar, effect_layer_get_layer(batt_fill_r)); statusbar_visible(); toggle_statusbar(); + refresh_stat_slots(); // place/raise the battery fills now the layers exist // topmost inverter layer, determines dark or light... inverter_layer = effect_layer_create(bounds); effect_layer_add_effect(inverter_layer, effect_invert, NULL); - if (settings.inverted==0) { + if (settings_get()->inverted==0) { layer_set_hidden(effect_layer_get_layer(inverter_layer), true); } layer_add_child(window_layer, effect_layer_get_layer(inverter_layer)); @@ -1541,6 +1521,7 @@ static void window_unload(Window *window) { // unload anything we loaded, destroy anything we created, remove anything we added layer_destroy(effect_layer_get_layer(inverter_layer)); layer_destroy(effect_layer_get_layer(battery_meter_layer)); + layer_destroy(effect_layer_get_layer(batt_fill_r)); layer_destroy(text_layer_get_layer(text_battery_layer)); layer_destroy(text_layer_get_layer(text_connection_layer)); layer_destroy(text_layer_get_layer(ampm_layer)); @@ -1548,21 +1529,23 @@ static void window_unload(Window *window) { layer_destroy(text_layer_get_layer(week_layer)); layer_destroy(text_layer_get_layer(time_layer)); layer_destroy(text_layer_get_layer(date_layer)); - layer_destroy(weather_layer); - layer_destroy(splash_layer); - layer_destroy(calendar_layer); + layer_destroy(text_layer_get_layer(ctr_r_layer)); + weather_destroy(); + splash_destroy(); + calendar_destroy(); layer_destroy(datetime_layer); layer_destroy(battery_layer); // custom fonts are automatically unloaded at exit - http://forums.getpebble.com/discussion/comment/35808/#Comment_35808 layer_remove_from_parent(bitmap_layer_get_layer(bmp_charging_layer)); - layer_remove_from_parent(bitmap_layer_get_layer(bmp_connection_layer)); bitmap_layer_destroy(bmp_charging_layer); - bitmap_layer_destroy(bmp_connection_layer); - gbitmap_destroy(image_connection_icon); - gbitmap_destroy(image_noconnection_icon); gbitmap_destroy(image_charging_icon); gbitmap_destroy(image_hourvibe_icon); gbitmap_destroy(image_dnd_icon); + bitmap_layer_destroy(bmp_phone_layer); + bitmap_layer_destroy(bmp_watch_layer); + gbitmap_destroy(image_phone_icon); + gbitmap_destroy(image_watch_icon); + gbitmap_destroy(image_bt16_icon); layer_destroy(slot_bot); layer_destroy(slot_top); layer_destroy(statusbar); @@ -1581,7 +1564,7 @@ void handle_vibe_suppression() { // it is useful to set it true directly (briefly), to ensure suppression, and then call this function afterwards if (dnd_period_active || battery_plugged) { vibe_suppression = true; - } else if (settings.vibe_hour && vibe_period_active) { + } else if (settings_get()->vibe_hour && vibe_period_active) { vibe_suppression = false; } else { vibe_suppression = false; @@ -1591,17 +1574,19 @@ void handle_vibe_suppression() { void handle_minute_tick(struct tm *tick_time, TimeUnits units_changed) { *currentTime = *tick_time; + apply_palette(); update_time_text(); + refresh_stat_slots(); // keep time-based status-bar slots current if ( currentTime->tm_min % 10 == 0) { dnd_period_check(); hourvibe_period_check(); set_status_charging_icon(); handle_vibe_suppression(); } - if (bluetooth_connected && adv_settings.weather_update) { - if (adv_settings.weather_update && (currentTime->tm_min + 60) % adv_settings.weather_update == 0) { + if (bluetooth_connected && adv_settings_get()->weather_update) { + if (adv_settings_get()->weather_update && (currentTime->tm_min + 60) % adv_settings_get()->weather_update == 0) { weather_request = app_timer_register(1000, &request_weather, NULL); - } else if (weather.current == 999 && weather.requests < 5) { + } else if (weather_state()->current == 999 && weather_state()->requests < 5) { // ANDROIIIIIDRAGE (or, someone who's got weather enabled but location services disabled) if (weather_request == NULL) { weather_request = app_timer_register(1000, &request_weather, NULL); } // for Android's slow JS... } @@ -1613,14 +1598,14 @@ void handle_minute_tick(struct tm *tick_time, TimeUnits units_changed) if (units_changed & HOUR_UNIT) { request_timezone(NULL); update_datetime_subtext(); - if (settings.vibe_hour && vibe_period_active) { - generate_vibe(settings.vibe_hour); // will be suppressed if within DND + if (settings_get()->vibe_hour && vibe_period_active) { + generate_vibe(settings_get()->vibe_hour); // will be suppressed if within DND } } if (units_changed & DAY_UNIT) { layer_mark_dirty(datetime_layer); - layer_mark_dirty(calendar_layer); + calendar_mark_dirty(); } // calendar gets redrawn every time because time_layer is changed and all layers are redrawn together. @@ -1629,11 +1614,14 @@ void handle_minute_tick(struct tm *tick_time, TimeUnits units_changed) void handle_second_tick(struct tm *tick_time, TimeUnits units_changed) { *currentTime = *tick_time; - // update the seconds layer(s)... - if (settings.show_week == 6) { + // update the seconds layer(s)... (9 == Seconds in the unified slot menu) + if (settings_get()->show_week == 9) { update_seconds_text(week_layer); } - if (settings.show_am_pm == 6) { + if (settings_get()->show_day == 9) { + update_seconds_text(day_layer); + } + if (settings_get()->show_am_pm == 9) { update_seconds_text(ampm_layer); } // redraw everything else if the minute changes... @@ -1643,8 +1631,9 @@ void handle_second_tick(struct tm *tick_time, TimeUnits units_changed) } static int need_second_tick_handler(void) { - if ((settings.show_week == 6) || (settings.show_am_pm == 6)) { return 1; } - return 0; + // 9 == Seconds in the unified slot menu + if ((settings_get()->show_week == 9) || (settings_get()->show_day == 9) || (settings_get()->show_am_pm == 9)) { return 1; } + return 0; } static void switch_tick_handler(void) { @@ -1652,20 +1641,20 @@ static void switch_tick_handler(void) { seconds_shown = need_second_tick_handler(); if (seconds_shown) { tick_timer_service_subscribe(SECOND_UNIT, &handle_second_tick); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Seconds handler enabled"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Seconds handler enabled"); } } else { tick_timer_service_subscribe(MINUTE_UNIT, &handle_minute_tick); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Seconds handler disabled"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Seconds handler disabled"); } } } void my_out_sent_handler(DictionaryIterator *sent, void *context) { // outgoing message was delivered - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Delivered"); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Delivered"); } } void my_out_fail_handler(DictionaryIterator *failed, AppMessageResult reason, void *context) { // outgoing message failed - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Failed to Send: %d", reason); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Failed to Send: %d", reason); } } void in_js_ready_handler(DictionaryIterator *received, void *context) { @@ -1675,16 +1664,27 @@ void in_js_ready_handler(DictionaryIterator *received, void *context) { void in_weather_handler(DictionaryIterator *received, void *context) { Tuple *appkey = dict_find(received, AK_WEATHER_TEMP); - if (appkey != NULL) { weather.current = appkey->value->int16; } + if (appkey != NULL) { weather_state()->current = appkey->value->int16; } appkey = dict_find(received, AK_WEATHER_COND); - if (appkey != NULL) { strncpy(weather.condition, appkey->value->cstring, sizeof(weather.condition)-1); } - layer_mark_dirty(weather_layer); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Weather received [%d/%d]: %d, %s", weather.failures, weather.requests, weather.current, weather.condition); } - if (weather.current == 999) { - weather.failures++; + if (appkey != NULL) { strncpy(weather_state()->condition, appkey->value->cstring, sizeof(weather_state()->condition)-1); } + appkey = dict_find(received, AK_WEATHER_CITY); + if (appkey != NULL) { strncpy(weather_state()->city, appkey->value->cstring, sizeof(weather_state()->city)-1); } + // Coordinates feed the sunrise/sunset complications and the Auto theme. + Tuple *lat = dict_find(received, AK_WEATHER_LAT); + Tuple *lon = dict_find(received, AK_WEATHER_LON); + if (lat != NULL && lon != NULL) { + strncpy(adv_settings_get()->weather_lat, lat->value->cstring, sizeof(adv_settings_get()->weather_lat)-1); + strncpy(adv_settings_get()->weather_lon, lon->value->cstring, sizeof(adv_settings_get()->weather_lon)-1); + persist_write_data(PK_ADV_SETTINGS, adv_settings_get(), sizeof(persist_adv_settings)); + apply_palette(); // Auto theme may flip with a known location + } + weather_mark_dirty(); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Weather received [%d/%d]: %d, %s", weather_state()->failures, weather_state()->requests, weather_state()->current, weather_state()->condition); } + if (weather_state()->current == 999) { + weather_state()->failures++; } else { - weather.requests = 0; - weather.failures = 0; + weather_state()->requests = 0; + weather_state()->failures = 0; } } @@ -1694,7 +1694,33 @@ void in_timezone_handler(DictionaryIterator *received, void *context) { timezone_offset = tz_offset->value->int8; update_datetime_subtext(); } - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Timezone received: %d", timezone_offset); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Timezone received: %d", timezone_offset); } +} + +// Recompute the adaptive layout from the current settings and reposition every +// band live (no watchface reload). Called after config so enabling/disabling a +// row redistributes the freed space immediately. +static void relayout(void) { + if (!slot_top) { return; } // not built yet + compute_layout(DEVICE_WIDTH, DEVICE_HEIGHT); + GRect stat = GRect(0, LAYOUT_STAT, DEVICE_WIDTH, LAYOUT_SLOT_TOP); + layer_set_frame(statusbar, stat); + layer_set_frame(slot_status, stat); + layer_set_frame(slot_top, GRect(0, LAYOUT_SLOT_TOP, DEVICE_WIDTH, LAYOUT_SLOT_HEIGHT)); + layer_set_frame(slot_bot, GRect(0, LAYOUT_SLOT_BOT, DEVICE_WIDTH, LAYOUT_SLOT_BOT_HEIGHT)); + layer_set_frame(datetime_layer, GRect(0, 0, DEVICE_WIDTH, LAYOUT_SLOT_HEIGHT)); + layer_set_frame(battery_layer, GRect(0, 0, DEVICE_WIDTH, LAYOUT_SLOT_TOP)); + calendar_set_frame(GRect(0, 0, DEVICE_WIDTH, LAYOUT_SLOT_BOT_HEIGHT)); + splash_set_frame(GRect(0, 0, DEVICE_WIDTH, LAYOUT_SLOT_BOT_HEIGHT)); + text_layer_set_font(time_layer, fonts_get_system_font(time_font_key())); // band height may have changed + position_time_layer(); // time + weather frames within the new time band + statusbar_visible(); + toggle_statusbar(); + apply_center(); + apply_bottom(); + refresh_stat_slots(); // after toggle so the battery fills stay on top + calendar_mark_dirty(); + layer_mark_dirty(datetime_layer); } void in_configuration_handler(DictionaryIterator *received, void *context) { @@ -1704,11 +1730,11 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { Tuple *debugging = dict_find(received, AK_DEBUGGING_ON); if (debugging != NULL) { if (debugging->value->uint8 != 0) { - debug.general = true; + debug_get()->general = true; app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Debugging enabled."); } else { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Debugging disabled."); } - debug.general = false; + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Debugging disabled."); } + debug_get()->general = false; } } @@ -1716,18 +1742,18 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { Tuple *debuglang = dict_find(received, AK_DEBUGLANG_ON); if (debuglang != NULL) { if (debuglang->value->uint8 != 0) { - debug.language = true; - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language debugging enabled."); } + debug_get()->language = true; + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language debugging enabled."); } } else { - debug.language = false; - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language debugging disabled."); } + debug_get()->language = false; + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language debugging disabled."); } } } // style_inv == inverted Tuple *style_inv = dict_find(received, AK_STYLE_INV); if (style_inv != NULL) { - settings.inverted = style_inv->value->uint8; + settings_get()->inverted = style_inv->value->uint8; if (style_inv->value->uint8==0) { layer_set_hidden(effect_layer_get_layer(inverter_layer), true); // hide inversion = dark } else { @@ -1738,40 +1764,47 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // style_day_inv == day_invert Tuple *style_day_inv = dict_find(received, AK_STYLE_DAY_INV); if (style_day_inv != NULL) { - settings.day_invert = style_day_inv->value->uint8; + settings_get()->day_invert = style_day_inv->value->uint8; } // style_grid == grid Tuple *style_grid = dict_find(received, AK_STYLE_GRID); if (style_grid != NULL) { - settings.grid = style_grid->value->uint8; + settings_get()->grid = style_grid->value->uint8; } // AK_VIBE_HOUR == vibe_hour - vibration patterns for hourly vibration Tuple *vibe_hour = dict_find(received, AK_VIBE_HOUR); if (vibe_hour != NULL) { - settings.vibe_hour = vibe_hour->value->uint8; + settings_get()->vibe_hour = vibe_hour->value->uint8; set_status_charging_icon(); } // INTL_DOWO == dayOfWeekOffset Tuple *INTL_DOWO = dict_find(received, AK_INTL_DOWO); if (INTL_DOWO != NULL) { - settings.dayOfWeekOffset = INTL_DOWO->value->uint8; + settings_get()->dayOfWeekOffset = INTL_DOWO->value->uint8; } // AK_INTL_FMT_DATE == date format (strftime + manual localization) Tuple *FMT_DATE = dict_find(received, AK_INTL_FMT_DATE); if (FMT_DATE != NULL) { - settings.date_format = FMT_DATE->value->uint8; + settings_get()->date_format = FMT_DATE->value->uint8; + update_date_text(); + } + + // AK_STRFTIME_FORMAT == custom strftime string used when date_format == 255 + Tuple *sfmt = dict_find(received, AK_STRFTIME_FORMAT); + if (sfmt != NULL) { + strncpy(adv_settings_get()->custom_date_fmt, sfmt->value->cstring, sizeof(adv_settings_get()->custom_date_fmt)-1); update_date_text(); } // AK_STYLE_WEEK Tuple *style_week = dict_find(received, AK_STYLE_WEEK); if (style_week != NULL) { - settings.show_week = style_week->value->uint8; - if ( settings.show_week ) { + settings_get()->show_week = style_week->value->uint8; + if ( settings_get()->show_week ) { layer_set_hidden(text_layer_get_layer(week_layer), false); } else { layer_set_hidden(text_layer_get_layer(week_layer), true); @@ -1781,14 +1814,14 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // AK_INTL_FMT_WEEK == week format (strftime) Tuple *FMT_WEEK = dict_find(received, AK_INTL_FMT_WEEK); if (FMT_WEEK != NULL) { - settings.week_format = FMT_WEEK->value->uint8; + settings_get()->week_format = FMT_WEEK->value->uint8; } // AK_STYLE_DAY Tuple *style_day = dict_find(received, AK_STYLE_DAY); if (style_day != NULL) { - settings.show_day = style_day->value->uint8; - if ( settings.show_day ) { + settings_get()->show_day = style_day->value->uint8; + if ( settings_get()->show_day ) { layer_set_hidden(text_layer_get_layer(day_layer), false); } else { layer_set_hidden(text_layer_get_layer(day_layer), true); @@ -1798,14 +1831,31 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // AK_STYLE_AM_PM Tuple *style_am_pm = dict_find(received, AK_STYLE_AM_PM); if (style_am_pm != NULL) { - settings.show_am_pm = style_am_pm->value->uint8; - if ( settings.show_am_pm ) { + settings_get()->show_am_pm = style_am_pm->value->uint8; + if ( settings_get()->show_am_pm ) { layer_set_hidden(text_layer_get_layer(ampm_layer), false); } else { layer_set_hidden(text_layer_get_layer(ampm_layer), true); } } + // Status-bar slots (left/right) — configurable like the rest. + Tuple *slot_stat_l = dict_find(received, AK_SLOT_STAT_L); + if (slot_stat_l != NULL) { settings_get()->slot_stat_l = slot_stat_l->value->uint8; } + Tuple *slot_stat_r = dict_find(received, AK_SLOT_STAT_R); + if (slot_stat_r != NULL) { settings_get()->slot_stat_r = slot_stat_r->value->uint8; } + if (slot_stat_l != NULL || slot_stat_r != NULL) { refresh_stat_slots(); } + + // Center-row slots (above the time). + Tuple *slot_ctr_l = dict_find(received, AK_SLOT_CTR_L); + if (slot_ctr_l != NULL) { settings_get()->slot_ctr_l = slot_ctr_l->value->uint8; } + Tuple *slot_ctr_r = dict_find(received, AK_SLOT_CTR_R); + if (slot_ctr_r != NULL) { settings_get()->slot_ctr_r = slot_ctr_r->value->uint8; } + if (slot_ctr_l != NULL || slot_ctr_r != NULL) { apply_center(); } + + Tuple *batt_style = dict_find(received, AK_BATT_STYLE); + if (batt_style != NULL) { settings_get()->batt_style = batt_style->value->uint8; refresh_stat_slots(); } + if (need_second_tick_handler() != seconds_shown) { switch_tick_handler(); } @@ -1816,18 +1866,18 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // AK_VIBE_PAT_DISCONNECT / AK_VIBE_PAT_CONNECT == vibration patterns for connect and disconnect Tuple *VIBE_PAT_D = dict_find(received, AK_VIBE_PAT_DISCONNECT); if (VIBE_PAT_D != NULL) { - settings.vibe_pat_disconnect = VIBE_PAT_D->value->uint8; + settings_get()->vibe_pat_disconnect = VIBE_PAT_D->value->uint8; } Tuple *VIBE_PAT_C = dict_find(received, AK_VIBE_PAT_CONNECT); if (VIBE_PAT_C != NULL) { - settings.vibe_pat_connect = VIBE_PAT_C->value->uint8; + settings_get()->vibe_pat_connect = VIBE_PAT_C->value->uint8; } // AK_TRACK_BATTERY == whether or not to do battery tracking Tuple *track_battery = dict_find(received, AK_TRACK_BATTERY); if (track_battery != NULL) { - settings.track_battery = track_battery->value->uint8; - if (settings.track_battery) { + settings_get()->track_battery = track_battery->value->uint8; + if (settings_get()->track_battery) { battery_status_send(NULL); // either it was just turned on, or we'll get a bonus datapoint from running config. } } @@ -1835,96 +1885,111 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // AK_CAL_WEEK_PATTERN == which weeks are shown in calendar (last,current,next - etc.) Tuple *week_pattern = dict_find(received, AK_CAL_WEEK_PATTERN); if (week_pattern != NULL) { - adv_settings.week_pattern = week_pattern->value->uint8; + adv_settings_get()->week_pattern = week_pattern->value->uint8; } Tuple *appkey; // AK_INV_SLOT_STAT == invert slot (or not!) // TODO, UNUSED appkey = dict_find(received, AK_INV_SLOT_STAT); - if (appkey != NULL) { adv_settings.invertStatBar = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->invertStatBar = appkey->value->uint8; } // AK_INV_SLOT_TOP == invert slot (or not!) // TODO, UNUSED appkey = dict_find(received, AK_INV_SLOT_TOP); - if (appkey != NULL) { adv_settings.invertTopSlot = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->invertTopSlot = appkey->value->uint8; } // AK_INV_SLOT_BOT == invert slot (or not!) // TODO, UNUSED appkey = dict_find(received, AK_INV_SLOT_BOT); - if (appkey != NULL) { adv_settings.invertBotSlot = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->invertBotSlot = appkey->value->uint8; } // AK_SHOW_STAT_BAR == show statusbar appkey = dict_find(received, AK_SHOW_STAT_BAR); - if (appkey != NULL) { adv_settings.showStatus = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->showStatus = appkey->value->uint8; } // AK_SHOW_STAT_BATT == statusbar battery limit appkey = dict_find(received, AK_SHOW_STAT_BATT); - if (appkey != NULL) { adv_settings.showStatusBat = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->showStatusBat = appkey->value->uint8; } + + // AK_CLOCK2_TZ == second time zone UTC offset (whole hours, signed) + appkey = dict_find(received, AK_CLOCK2_TZ); + if (appkey != NULL) { adv_settings_get()->clock2_tz = appkey->value->int8; } // AK_SHOW_DATE == show date // TODO, UNUSED appkey = dict_find(received, AK_SHOW_DATE); - if (appkey != NULL) { adv_settings.showDate = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->showDate = appkey->value->uint8; } // AK_DND_START == period start, DND appkey = dict_find(received, AK_DND_START); - if (appkey != NULL) { adv_settings.DND_start = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->DND_start = appkey->value->uint8; } // AK_DND_STOP == period stop, DND appkey = dict_find(received, AK_DND_STOP); - if (appkey != NULL) { adv_settings.DND_stop = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->DND_stop = appkey->value->uint8; } - // AK_DND_NOACCEL == [perhaps] disable accelerometer during DND // TODO, UNUSED + // AK_DND_NOACCEL == DND mode (0 off, 1 follow watch Quiet Time, 2 app window) appkey = dict_find(received, AK_DND_NOACCEL); - if (appkey != NULL) { adv_settings.DND_accel_off = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->dnd_mode = appkey->value->uint8; } // AK_VIBE_START == period start, VIBE appkey = dict_find(received, AK_VIBE_START); - if (appkey != NULL) { adv_settings.vibe_hour_start = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->vibe_hour_start = appkey->value->uint8; } // AK_VIBE_STOP == period stop, VIBE appkey = dict_find(received, AK_VIBE_STOP); - if (appkey != NULL) { adv_settings.vibe_hour_stop = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->vibe_hour_stop = appkey->value->uint8; } // AK_VIBE_DAYS == days to do hourly vibration // TODO, UNUSED appkey = dict_find(received, AK_VIBE_DAYS); - if (appkey != NULL) { adv_settings.vibe_hour_days = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->vibe_hour_days = appkey->value->uint8; } // AK_IDLE_REMINDER == period stop, VIBE // TODO, UNUSED appkey = dict_find(received, AK_IDLE_REMINDER); - if (appkey != NULL) { adv_settings.idle_reminder = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->idle_reminder = appkey->value->uint8; } // AK_IDLE_VIBE_PATT == idle vibration pattern // TODO, UNUSED appkey = dict_find(received, AK_IDLE_VIBE_PATT); - if (appkey != NULL) { adv_settings.idle_pattern = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->idle_pattern = appkey->value->uint8; } /* TODO // AK_IDLE_MESSAGE == Idle message // TODO, UNUSED appkey = dict_find(received, AK_IDLE_MESSAGE); - if (appkey != NULL) { strncpy(adv_settings.idle_message, appkey->value->cstring, sizeof(adv_settings.idle_message)-1); } + if (appkey != NULL) { strncpy(adv_settings_get()->idle_message, appkey->value->cstring, sizeof(adv_settings_get()->idle_message)-1); } */ // AK_IDLE_START == period start, Idle reminder // TODO, UNUSED appkey = dict_find(received, AK_IDLE_START); - if (appkey != NULL) { adv_settings.idle_start = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->idle_start = appkey->value->uint8; } // AK_IDLE_STOP == period stop, Idle reminder // TODO, UNUSED appkey = dict_find(received, AK_IDLE_STOP); - if (appkey != NULL) { adv_settings.idle_stop = appkey->value->uint8; } + if (appkey != NULL) { adv_settings_get()->idle_stop = appkey->value->uint8; } // AK_WEATHER_FMT == weather format (0:C / 1:F) appkey = dict_find(received, AK_WEATHER_FMT); if (appkey != NULL) { - adv_settings.weather_format = appkey->value->uint8; + adv_settings_get()->weather_format = appkey->value->uint8; if (weather_request == NULL) { weather_request = app_timer_register(1000, &request_weather, NULL); } } // AK_WEATHER_UPDATE == weather update frequency appkey = dict_find(received, AK_WEATHER_UPDATE); if (appkey != NULL) { - if (appkey->value->uint8 < adv_settings.weather_update && weather_request == NULL) { + if (appkey->value->uint8 < adv_settings_get()->weather_update && weather_request == NULL) { weather_request = app_timer_register(1000, &request_weather, NULL); } - adv_settings.weather_update = appkey->value->uint8; + adv_settings_get()->weather_update = appkey->value->uint8; } + + // AK_THEME == color theme id; AK_THEME_MODE == 0 light / 1 dark / 2 auto + appkey = dict_find(received, AK_THEME); + if (appkey != NULL && appkey->value->uint8 < THEME_COUNT) { + settings_get()->theme = appkey->value->uint8; + } + appkey = dict_find(received, AK_THEME_MODE); + if (appkey != NULL && appkey->value->uint8 <= THEME_MODE_AUTO) { + settings_get()->theme_mode = appkey->value->uint8; + } + statusbar_visible(); toggle_weather(); toggle_statusbar(); @@ -1935,8 +2000,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // AK_LANGUAGE == language, e.g. EN Tuple *chosen_language = dict_find(received, AK_LANGUAGE); if (chosen_language != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language is set to %s", chosen_language->value->cstring); } - strncpy(lang_gen.language, chosen_language->value->cstring, sizeof(lang_gen.language)-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Language is set to %s", chosen_language->value->cstring); } + strncpy(lang_gen_get()->language, chosen_language->value->cstring, sizeof(lang_gen_get()->language)-1); set_unifont(); } @@ -1944,8 +2009,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_ABBR_SUNDAY; i <= AK_TRANS_ABBR_SATURDAY; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_gen.abbrDaysOfWeek[i - AK_TRANS_ABBR_SUNDAY], translation->value->cstring, sizeof(lang_gen.abbrDaysOfWeek[i - AK_TRANS_ABBR_SUNDAY])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_gen_get()->abbrDaysOfWeek[i - AK_TRANS_ABBR_SUNDAY], translation->value->cstring, sizeof(lang_gen_get()->abbrDaysOfWeek[i - AK_TRANS_ABBR_SUNDAY])-1); } } @@ -1953,8 +2018,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_SUNDAY; i <= AK_TRANS_SATURDAY; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_days.DaysOfWeek[i - AK_TRANS_SUNDAY], translation->value->cstring, sizeof(lang_days.DaysOfWeek[i - AK_TRANS_SUNDAY])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_days_get()->DaysOfWeek[i - AK_TRANS_SUNDAY], translation->value->cstring, sizeof(lang_days_get()->DaysOfWeek[i - AK_TRANS_SUNDAY])-1); } } @@ -1962,8 +2027,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_ABBR_JANUARY; i <= AK_TRANS_ABBR_DECEMBER; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_gen.abbrMonthsNames[i - AK_TRANS_ABBR_JANUARY], translation->value->cstring, sizeof(lang_gen.abbrMonthsNames[i - AK_TRANS_ABBR_JANUARY])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_gen_get()->abbrMonthsNames[i - AK_TRANS_ABBR_JANUARY], translation->value->cstring, sizeof(lang_gen_get()->abbrMonthsNames[i - AK_TRANS_ABBR_JANUARY])-1); } } @@ -1971,8 +2036,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_JANUARY; i <= AK_TRANS_DECEMBER; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_months.monthsNames[i - AK_TRANS_JANUARY], translation->value->cstring, sizeof(lang_months.monthsNames[i - AK_TRANS_JANUARY])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_months_get()->monthsNames[i - AK_TRANS_JANUARY], translation->value->cstring, sizeof(lang_months_get()->monthsNames[i - AK_TRANS_JANUARY])-1); } } @@ -1980,8 +2045,8 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_CONNECTED; i <= AK_TRANS_DISCONNECTED; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_gen.statuses[i - AK_TRANS_CONNECTED], translation->value->cstring, sizeof(lang_gen.statuses[i - AK_TRANS_CONNECTED])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_gen_get()->statuses[i - AK_TRANS_CONNECTED], translation->value->cstring, sizeof(lang_gen_get()->statuses[i - AK_TRANS_CONNECTED])-1); } } vibe_suppression = true; @@ -1992,26 +2057,26 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { for (int i = AK_TRANS_TIME_AM; i <= AK_TRANS_TIME_PM; i++ ) { translation = dict_find(received, i); if (translation != NULL) { - if (debug.language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } - strncpy(lang_gen.abbrTime[i - AK_TRANS_TIME_AM], translation->value->cstring, sizeof(lang_gen.abbrTime[i - AK_TRANS_TIME_AM])-1); + if (debug_get()->language) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "translation for key %d is %s", i, translation->value->cstring); } + strncpy(lang_gen_get()->abbrTime[i - AK_TRANS_TIME_AM], translation->value->cstring, sizeof(lang_gen_get()->abbrTime[i - AK_TRANS_TIME_AM])-1); } } // end translations... int result = 0; - result = persist_write_data(PK_SETTINGS, &settings, sizeof(settings) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into settings", result); } - result = persist_write_data(PK_LANG_GEN, &lang_gen, sizeof(lang_gen) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_gen", result); } - result = persist_write_data(PK_LANG_MONTHS, &lang_months, sizeof(lang_months) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_months", result); } - result = persist_write_data(PK_LANG_DAYS, &lang_days, sizeof(lang_days) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_days", result); } - result = persist_write_data(PK_DEBUGGING, &debug, sizeof(debug) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into debug", result); } - result = persist_write_data(PK_ADV_SETTINGS, &adv_settings, sizeof(adv_settings) ); - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into adv_settings", result); } + result = persist_write_data(PK_SETTINGS, settings_get(), sizeof(persist) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into settings", result); } + result = persist_write_data(PK_LANG_GEN, lang_gen_get(), sizeof(persist_general_lang) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_gen", result); } + result = persist_write_data(PK_LANG_MONTHS, lang_months_get(), sizeof(persist_months_lang) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_months", result); } + result = persist_write_data(PK_LANG_DAYS, lang_days_get(), sizeof(persist_days_lang) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into lang_days", result); } + result = persist_write_data(PK_DEBUGGING, debug_get(), sizeof(persist_debug) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into debug", result); } + result = persist_write_data(PK_ADV_SETTINGS, adv_settings_get(), sizeof(persist_adv_settings) ); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Wrote %d bytes into adv_settings", result); } // ==== Implemented SDK ==== // Battery @@ -2028,15 +2093,15 @@ void in_configuration_handler(DictionaryIterator *received, void *context) { // PebbleKit JS - more information from phone // ==== Future improvements ==== // Positioning - top, bottom, etc. - if (1) { layer_mark_dirty(calendar_layer); } // TODO - if (1) { layer_mark_dirty(datetime_layer); } // TODO + apply_palette(); // re-tint text + icons for the (possibly new) theme + relayout(); // recompute + reposition the bands live for the new settings } void my_in_rcv_handler(DictionaryIterator *received, void *context) { // incoming message received Tuple *message_type = dict_find(received, AK_MESSAGE_TYPE); if (message_type != NULL) { - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Message type %d received", message_type->value->uint8); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Message type %d received", message_type->value->uint8); } switch ( message_type->value->uint8 ) { case AK_SEND_WATCH_VERSION: in_js_ready_handler(received, context); @@ -2047,6 +2112,11 @@ void my_in_rcv_handler(DictionaryIterator *received, void *context) { case AK_REQUEST_WEATHER: in_weather_handler(received, context); return; + case AK_PHONE_BATTERY: { + Tuple *pb = dict_find(received, AK_PHONE_BATTERY); + if (pb != NULL) { phone_battery_percent = pb->value->uint8; set_connection_text(); } + return; + } } } else { // default to configuration, which may not send the message type... @@ -2056,7 +2126,7 @@ void my_in_rcv_handler(DictionaryIterator *received, void *context) { void my_in_drp_handler(AppMessageResult reason, void *context) { // incoming message dropped - if (debug.general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Dropped: %d", reason); } + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "AppMessage Dropped: %d", reason); } } static void app_message_init(void) { @@ -2077,40 +2147,40 @@ static void app_message_init(void) { static void init(void) { - if (DEBUGLOG == 1) { debug.general = true; } - if (TRANSLOG == 1) { debug.language = true; } + if (DEBUGLOG == 1) { debug_get()->general = true; } + if (TRANSLOG == 1) { debug_get()->language = true; } currentTime = get_time(); app_message_init(); if (persist_exists(PK_SETTINGS)) { - persist_read_data(PK_SETTINGS, &settings, sizeof(settings) ); - if (settings.version == 11) { // v11 -> v12 bugfix - if (settings.date_format > 234) { settings.date_format = settings.date_format + 1; } - settings.version = 12; + persist_read_data(PK_SETTINGS, settings_get(), sizeof(persist) ); + if (settings_get()->version == 11) { // v11 -> v12 bugfix + if (settings_get()->date_format > 234) { settings_get()->date_format = settings_get()->date_format + 1; } + settings_get()->version = 12; } if (persist_exists(PK_LANG_GEN)) { - persist_read_data(PK_LANG_GEN, &lang_gen, sizeof(lang_gen) ); + persist_read_data(PK_LANG_GEN, lang_gen_get(), sizeof(persist_general_lang) ); } if (persist_exists(PK_LANG_MONTHS)) { - persist_read_data(PK_LANG_MONTHS, &lang_months, sizeof(lang_months) ); + persist_read_data(PK_LANG_MONTHS, lang_months_get(), sizeof(persist_months_lang) ); } if (persist_exists(PK_LANG_DAYS)) { - persist_read_data(PK_LANG_DAYS, &lang_days, sizeof(lang_days) ); + persist_read_data(PK_LANG_DAYS, lang_days_get(), sizeof(persist_days_lang) ); } if (persist_exists(PK_DEBUGGING)) { - persist_read_data(PK_DEBUGGING, &debug, sizeof(debug) ); + persist_read_data(PK_DEBUGGING, debug_get(), sizeof(persist_debug) ); } - //persist_write_data(PK_ADV_SETTINGS, &adv_settings, sizeof(adv_settings) ); // XXX TODO reset to defaults, for testing... + //persist_write_data(PK_ADV_SETTINGS, adv_settings_get(), sizeof(persist_adv_settings) ); // XXX TODO reset to defaults, for testing... if (persist_exists(PK_ADV_SETTINGS)) { - persist_read_data(PK_ADV_SETTINGS, &adv_settings, sizeof(adv_settings) ); + persist_read_data(PK_ADV_SETTINGS, adv_settings_get(), sizeof(persist_adv_settings) ); } } // re-initialize this, if it was set, since we're storing those values persistently as well... - if (DEBUGLOG == 1) { debug.general = true; } - if (TRANSLOG == 1) { debug.language = true; } + if (DEBUGLOG == 1) { debug_get()->general = true; } + if (TRANSLOG == 1) { debug_get()->language = true; } - if (adv_settings.weather_update) { + if (adv_settings_get()->weather_update) { weather_request = app_timer_register(1250, &request_weather, NULL); //request_weather(NULL); } diff --git a/src/Timely.h b/src/Timely.h index cf83a4c..cd60ad3 100644 --- a/src/Timely.h +++ b/src/Timely.h @@ -3,96 +3,18 @@ */ // Create a struct to hold our persistent settings... -typedef struct persist { // 18 bytes - uint8_t version; // version key - uint8_t inverted; // Invert display - uint8_t day_invert; // Invert colors on today's date - uint8_t grid; // Show the grid - uint8_t vibe_hour; // vibrate at the top of the hour? - uint8_t dayOfWeekOffset; // first day of our week - uint8_t date_format; // date format - uint8_t show_am_pm; // Show AM/PM below time - uint8_t show_day; // Show day name below time - uint8_t show_week; // Show week number below time - uint8_t week_format; // week format (calculation, e.g. ISO 8601) - uint8_t vibe_pat_disconnect; // vibration pattern for disconnect - uint8_t vibe_pat_connect; // vibration pattern for connect - char *strftime_format; // custom date_format string (date_format = 255) - uint8_t track_battery; // track battery information -} __attribute__((__packed__)) persist; +#include "settings.h" -typedef struct persist_months_lang { // 252 bytes - char monthsNames[12][21]; // 252: 10-20 UTF8 characters for each of 12 months -// 252 bytes -} __attribute__((__packed__)) persist_months_lang; +#include "locale.h" -typedef struct persist_days_lang { // 238 bytes - char DaysOfWeek[7][34]; // 238: 16-33 UTF8 characters for each of 7 weekdays -// 238 bytes -} __attribute__((__packed__)) persist_days_lang; +#include "debug.h" -typedef struct persist_general_lang { // 253 bytes - char statuses[2][26]; // 40: 12-25 characters for each of 2 statuses - char abbrTime[2][12]; // 24: 5-11 characters for each of 2 abbreviations - char abbrDaysOfWeek[7][6]; // 42: 2- 5 characters for each of 7 weekdays abbreviations - char abbrMonthsNames[12][11]; // 132: 5-11 characters for each of 12 months abbreviations - char language[3]; // 3: 2 characters for language (internal, stored as ascii for convenience) -// 253 bytes -} __attribute__((__packed__)) persist_general_lang; -typedef struct persist_debug { // 6 bytes - bool general; // debugging messages (general) - bool language; // debugging messages (language/translation) - bool reserved_1; // debugging messages (reserved to spare updates later) - bool reserved_2; // debugging messages (reserved to spare updates later) - bool reserved_3; // debugging messages (reserved to spare updates later) - bool reserved_4; // debugging messages (reserved to spare updates later) -} __attribute__((__packed__)) persist_debug; -typedef struct persist_adv_settings { // 243 bytes - uint8_t week_pattern; // 1 byte - uint8_t invertStatBar; // 1 byte - uint8_t invertTopSlot; // 1 byte - uint8_t invertBotSlot; // 1 byte - uint8_t showStatus; // 1 byte - uint8_t showStatusBat; // 1 byte - uint8_t showDate; // 1 byte - uint8_t DND_start; // 1 byte - uint8_t DND_stop; // 1 byte - uint8_t DND_accel_off; // 1 byte - uint8_t vibe_hour_start; // 1 byte - uint8_t vibe_hour_stop; // 1 byte - uint8_t vibe_hour_days; // 1 byte - uint8_t idle_reminder; // 1 byte - uint8_t idle_pattern; // 1 byte - char idle_message[32]; // 32 bytes - uint8_t idle_start; // 1 byte - uint8_t idle_stop; // 1 byte - int8_t clock2_tz; // 1 byte - char clock2_desc[32]; // 32 bytes - uint8_t weather_format; // 1 byte - uint8_t weather_update; // 1 byte - char weather_lat[8]; // 8 bytes - char weather_lon[8]; // 8 bytes - uint8_t clock_font; // 1 byte - uint8_t token_type[2]; // 2 bytes - char token_code[2][65]; //130 bytes - uint8_t slots[10]; // 10 bytes -} __attribute__((__packed__)) persist_adv_settings; - -typedef struct weather_data { - int16_t current; // current temperature - char condition[2]; // weather_conditions (mapped to single character in font) - int16_t requests; // requests we've put out since last success - int16_t failures; // failed responses that have come back since last success -} __attribute__((__packed__)) weather_data; +#include "weather.h" char *translate_error(AppMessageResult result); -int daysInMonth(int mon, int year); struct tm *get_time(); -void setColors(GContext* ctx); -void setInvColors(GContext* ctx); -void weather_layer_update_callback(Layer *me, GContext* ctx); void splash_layer_update_callback(Layer *me, GContext* ctx); void calendar_layer_update_callback(Layer *me, GContext* ctx); void update_date_text(); diff --git a/src/calendar.c b/src/calendar.c new file mode 100644 index 0000000..866e259 --- /dev/null +++ b/src/calendar.c @@ -0,0 +1,58 @@ +#include "calendar.h" +#include "timefmt.h" // daysInMonth + +CalGrid calendar_build(int year, int mon, int mday, int wday, + int dow_offset, int week_pattern) { + CalGrid g; + int daysThisMonth = daysInMonth(mon, year); + int specialDay = wday - dow_offset; + + int show_last = 1, show_next = 1; + switch (week_pattern) { + case 1: show_last = 2; show_next = 0; break; + case 2: show_last = 0; show_next = 2; break; + default: break; + } + + int cellNum = 0; + int daysVisPrevMonth = 0, daysVisNextMonth = 0; + int daysPriorToToday = specialDay; + int daysAfterToday = (6 - specialDay) % 7; + + // tm_wday is Sunday-based; the configured start-of-week may differ. + if (wday < dow_offset) { + daysPriorToToday += 7 * (show_last + 1); + specialDay += 7; + } else { + daysPriorToToday += 7 * show_last; + } + daysAfterToday += 7 * show_next; + + if (daysPriorToToday >= mday) { + int daysInPrevMonth = daysInMonth(mon - 1, year); + daysVisPrevMonth = daysPriorToToday - mday + 1; + for (int i = 0; i < daysVisPrevMonth; i++, cellNum++) + g.days[cellNum] = daysInPrevMonth + i - daysVisPrevMonth + 1; + } + + int firstDayShownThisMonth = daysVisPrevMonth + mday - daysPriorToToday; + for (int i = firstDayShownThisMonth; i < mday; i++, cellNum++) + g.days[cellNum] = i; + + g.days[cellNum] = mday; + cellNum++; + + if (mday + daysAfterToday > daysThisMonth) + daysVisNextMonth = mday + daysAfterToday - daysThisMonth; + + int daysLeftThisMonth = daysAfterToday - daysVisNextMonth; + for (int i = 0; i < daysLeftThisMonth; i++, cellNum++) + g.days[cellNum] = i + mday + 1; + + for (int i = 0; i < daysVisNextMonth; i++, cellNum++) + g.days[cellNum] = i + 1; + + g.special_col = specialDay; + g.special_row = show_last + 1; + return g; +} diff --git a/src/calendar.h b/src/calendar.h new file mode 100644 index 0000000..5bf749e --- /dev/null +++ b/src/calendar.h @@ -0,0 +1,14 @@ +#pragma once +// 3-week calendar grid model (pure; no Pebble deps). Rendering stays in Timely.c. + +typedef struct { + int days[21]; // day-of-month numbers, row-major 3 weeks x 7 columns + int special_col; // column [0-6] holding today + int special_row; // week row [1-3] holding today +} CalGrid; + +// Build the grid. year=full (2026), mon=0-11, mday=1-31, wday=0(Sun)-6(Sat), +// dow_offset=start-of-week offset 0-6, week_pattern: 0=1 before+1 after, +// 1=2 before, 2=2 after. +CalGrid calendar_build(int year, int mon, int mday, int wday, + int dow_offset, int week_pattern); diff --git a/src/calendar_view.c b/src/calendar_view.c new file mode 100644 index 0000000..d41b1c2 --- /dev/null +++ b/src/calendar_view.c @@ -0,0 +1,143 @@ +#include "calendar.h" +#include "calendar_view.h" +#include "layout.h" +#include "theme.h" +#include "locale.h" +#include "settings.h" +#include "debug.h" +#include "ui.h" +#include + +static Layer *s_calendar_layer; + +static void calendar_render(Layer *me, GContext* ctx) { + (void)me; + TimelyLayout L = layout_get(); + const int CAL_WIDTH = L.cal_cell_w, CAL_HEIGHT = L.cal_cell_h, DEVICE_WIDTH = L.statusbar.w; + const int CAL_DAYS = 7, CAL_GAP = 1, CAL_LEFT = 2; + + (void)me; + + CalGrid grid = calendar_build(currentTime->tm_year + 1900, + currentTime->tm_mon, + currentTime->tm_mday, + currentTime->tm_wday, + settings_get()->dayOfWeekOffset, + adv_settings_get()->week_pattern); + int *calendar = grid.days; + int specialDay = grid.special_col; + Palette pal = theme_palette(); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Calendar - sCol: %d, sRow: %d", grid.special_col, grid.special_row); } + +// --------------------------- +// Now that we've calculated which days go where, we'll move on to the display logic. +// --------------------------- + + + int weeks = 3; // always display 3 weeks: # previous, current, # next + + GFont current = cal_normal; + int font_vert_offset = -4; // lift the weekday header clear of the grid below it + if (strcmp(lang_gen_get()->language,"RU") == 0 ) { font_vert_offset = -6; } + + // generate a light background for the calendar grid + if (settings_get()->grid) { + setInvColors(ctx); + graphics_fill_rect(ctx, GRect (CAL_LEFT + CAL_GAP, CAL_HEIGHT - CAL_GAP, DEVICE_WIDTH - 2 * (CAL_LEFT + CAL_GAP), CAL_HEIGHT * weeks), 0, GCornerNone); + setColors(ctx); + } + for (int col = 0; col < CAL_DAYS; col++) { + + // Adjust labels by specified offset + int weekday = col + settings_get()->dayOfWeekOffset; + if (weekday > 6) { weekday -= 7; } + bool weekend = (weekday == 0 || weekday == 6); // Sun / Sat + graphics_context_set_text_color(ctx, weekend ? pal.weekend : pal.fg); + + if (col == specialDay) { + // Today's header is bold but the SAME size as the others (cal_bold is the + // big 18px date font, which overflows onto the grid); RU has no Latin + // bold so it keeps the unicode font + double-strike below. + if (strcmp(lang_gen_get()->language,"RU") == 0 ) { + current = cal_bold; + font_vert_offset = -6; + } else { + current = fonts_get_system_font(FONT_KEY_GOTHIC_14_BOLD); + font_vert_offset = -4; // same size as the others -> same offset + } + } + // draw the cell background + // graphics_fill_rect(ctx, GRect (CAL_WIDTH * col + CAL_LEFT + CAL_GAP, 0, CAL_WIDTH - CAL_GAP, CAL_HEIGHT - CAL_GAP), 0, GCornerNone); + + // draw the cell text + graphics_draw_text(ctx, lang_gen_get()->abbrDaysOfWeek[weekday], current, GRect(CAL_WIDTH * col + CAL_LEFT + CAL_GAP, CAL_GAP + font_vert_offset, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + if (col == specialDay) { + if (strcmp(lang_gen_get()->language,"RU") == 0 ) { + // we don't actually have a bold font for this, so we'll use font double-striking to simulate bold + graphics_draw_text(ctx, lang_gen_get()->abbrDaysOfWeek[weekday], current, GRect(CAL_WIDTH * col + CAL_LEFT + CAL_GAP + 1, CAL_GAP + font_vert_offset, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + } + current = cal_normal; + font_vert_offset = -4; // restore the header default (not 0) for columns after today + if (strcmp(lang_gen_get()->language,"RU") == 0 ) { font_vert_offset = -6; } + } + } + + GFont normal = fonts_get_system_font(FONT_KEY_GOTHIC_14); // fh = 16 + GFont bold = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); // fh = 22 + current = normal; + font_vert_offset = 0; + + // draw the individual calendar rows/columns + int week = 0; + int specialRow = grid.special_row; + + for (int row = 1; row <= 3; row++) { + week++; + for (int col = 0; col < CAL_DAYS; col++) { + if ( row == specialRow && col == specialDay) { + if (settings_get()->day_invert) { + setTodayColors(ctx); + } + current = bold; + font_vert_offset = -3; + } + + // draw the cell background + graphics_fill_rect(ctx, GRect (CAL_WIDTH * col + CAL_LEFT + CAL_GAP, CAL_HEIGHT * week, CAL_WIDTH - CAL_GAP, CAL_HEIGHT - CAL_GAP), 0, GCornerNone); + + if (!(row == specialRow && col == specialDay)) { + int wd = col + settings_get()->dayOfWeekOffset; + if (wd > 6) { wd -= 7; } + graphics_context_set_text_color(ctx, (wd == 0 || wd == 6) ? pal.weekend : pal.fg); + } + // draw the cell text + char date_text[3]; + snprintf(date_text, sizeof(date_text), "%d", calendar[col + 7 * (row - 1)]); + // vertically center the number in its cell (Pebble top-aligns text) + GSize ts = graphics_text_layout_get_content_size(date_text, current, + GRect(0, 0, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter); + // ts.h includes the font's top line-leading, biasing the glyph low; the + // leading scales with the font, so correct by a fraction of ts.h (~1/6) + // rather than a fixed px (adapts to normal/bold and per-theme fonts). + int ty = CAL_HEIGHT * week + (CAL_HEIGHT - CAL_GAP - ts.h) / 2 - ts.h / 6; + graphics_draw_text(ctx, date_text, current, GRect(CAL_WIDTH * col + CAL_LEFT, ty, CAL_WIDTH, CAL_HEIGHT), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + + if ( row == specialRow && col == specialDay) { + setColors(ctx); + current = normal; + font_vert_offset = 0; + } + } + } +} + +void calendar_create(Layer *parent, GRect frame) { + s_calendar_layer = layer_create(frame); + layer_set_update_proc(s_calendar_layer, calendar_render); + layer_add_child(parent, s_calendar_layer); + layer_set_hidden(s_calendar_layer, true); +} +void calendar_destroy(void) { layer_destroy(s_calendar_layer); } +void calendar_set_hidden(bool hidden) { layer_set_hidden(s_calendar_layer, hidden); } +void calendar_set_frame(GRect frame) { layer_set_frame(s_calendar_layer, frame); } +void calendar_mark_dirty(void) { layer_mark_dirty(s_calendar_layer); } diff --git a/src/calendar_view.h b/src/calendar_view.h new file mode 100644 index 0000000..7178631 --- /dev/null +++ b/src/calendar_view.h @@ -0,0 +1,8 @@ +#pragma once +#include +// Calendar display component (owns its layer; draws the calendar.h model). +void calendar_create(Layer *parent, GRect frame); +void calendar_destroy(void); +void calendar_set_hidden(bool hidden); +void calendar_set_frame(GRect frame); // resize/reposition on a live relayout +void calendar_mark_dirty(void); diff --git a/src/debug.c b/src/debug.c new file mode 100644 index 0000000..19cfaaa --- /dev/null +++ b/src/debug.c @@ -0,0 +1,8 @@ +#include "debug.h" +// All debugging disabled by default. +static persist_debug s_debug = { + .general = false, .language = false, + .reserved_1 = false, .reserved_2 = false, + .reserved_3 = false, .reserved_4 = false, +}; +persist_debug *debug_get(void) { return &s_debug; } diff --git a/src/debug.h b/src/debug.h new file mode 100644 index 0000000..e6e8da1 --- /dev/null +++ b/src/debug.h @@ -0,0 +1,13 @@ +#pragma once +#include +// Debug flags. State owned by debug.c; access via debug_get(). +typedef struct persist_debug { // 6 bytes + bool general; // general debugging messages + bool language; // language/translation debugging + bool reserved_1; + bool reserved_2; + bool reserved_3; + bool reserved_4; +} __attribute__((__packed__)) persist_debug; + +persist_debug *debug_get(void); diff --git a/src/effects.c b/src/effects.c index 1169823..9f56de2 100644 --- a/src/effects.c +++ b/src/effects.c @@ -277,17 +277,19 @@ void effect_lens(GContext* ctx, GRect position, void* param){ float focal = (int32_t)param >>8 & 0xFF;// focal point of lens float obj_dis = (int32_t)param & 0xFF;//distance of object from focal point. - for (int y = r; y >= 0; --y) - for (int x = r; x >= 0; --x) + for (int y = r; y >= 0; --y) { + for (int x = r; x >= 0; --x) { if (x*x+y*y < r*r) { int Y1= my_tan(my_asin(y/focal))*obj_dis; int X1= my_tan(my_asin(x/focal))*obj_dis; - set_pixel(bd,bpr, yCn +y, xCn +x, get_pixel(bd,bpr, yCn +Y1, xCn +X1)); + set_pixel(bd,bpr, yCn +y, xCn +x, get_pixel(bd,bpr, yCn +Y1, xCn +X1)); set_pixel(bd,bpr, yCn +y, xCn -x, get_pixel(bd,bpr, yCn +Y1, xCn -X1)); set_pixel(bd,bpr, yCn -y, xCn +x, get_pixel(bd,bpr, yCn -Y1, xCn +X1)); set_pixel(bd,bpr, yCn -y, xCn -x, get_pixel(bd,bpr, yCn -Y1, xCn -X1)); } + } + } graphics_release_frame_buffer(ctx, fb); //Todo: Change to lock-up arcsin table in the future. (Currently using floating point math library that is relatively big & slow) } @@ -335,7 +337,7 @@ void effect_mask(GContext* ctx, GRect position, void* param) { void effect_fps(GContext* ctx, GRect position, void* param) { static GFont font = NULL; - static char buff[16]; + static char buff[24]; time_t tt; uint16_t ms; diff --git a/src/js/pebble-js-app.js b/src/js/app.js similarity index 58% rename from src/js/pebble-js-app.js rename to src/js/app.js index 5acbdd8..9aa3af9 100644 --- a/src/js/pebble-js-app.js +++ b/src/js/app.js @@ -1,3 +1,11 @@ +var CONFIG_SPEC = require('./config'); +var buildConfigPage = require('./configpage'); + +// The bundled SunCalc library (below) used to attach itself to `window`; under +// the CommonJS bundler there is no global object, so it populates this +// module-scoped variable instead (see its assignment further down). +var SunCalc; + var CLIMACON = { 'cloud' : '!', 'cloud_day' : '"', @@ -76,231 +84,23 @@ var CLIMACON = { 'cloud_down' : 'j' }; -var OWMclimacon= { -// Thunderstorm - 200 : CLIMACON['lightning'], // thunderstorm with light rain - 201 : CLIMACON['lightning'], // thunderstorm with rain - 202 : CLIMACON['lightning'], // thunderstorm with heavy rain - 210 : CLIMACON['lightning'], // light thunderstorm - 211 : CLIMACON['lightning'], // thunderstorm - 212 : CLIMACON['lightning'], // heavy thunderstorm - 221 : CLIMACON['lightning'], // ragged thunderstorm - 230 : CLIMACON['lightning'], // thunderstorm with light drizzle - 231 : CLIMACON['lightning'], // thunderstorm with drizzle - 232 : CLIMACON['lightning'], // thunderstorm with heavy drizzle -// Drizzle - 300 : CLIMACON['drizzle'], // light intensity drizzle - 301 : CLIMACON['drizzle'], // drizzle - 302 : CLIMACON['drizzle'], // heavy intensity drizzle - 310 : CLIMACON['drizzle'], // light intensity drizzle rain - 311 : CLIMACON['drizzle'], // drizzle rain - 312 : CLIMACON['drizzle'], // heavy intensity drizzle rain - 313 : CLIMACON['showers'], // shower rain and drizzle - 314 : CLIMACON['showers'], // heavy shower rain and drizzle - 321 : CLIMACON['showers'], // shower drizzle -// Rain - 500 : CLIMACON['rain'], // light rain - 501 : CLIMACON['rain'], // moderate rain - 502 : CLIMACON['downpour'], // heavy intensity rain - 503 : CLIMACON['downpour'], // very heavy rain - 504 : CLIMACON['downpour'], // extreme rain - 511 : CLIMACON['downpour'], // freezing rain - 520 : CLIMACON['showers'], // light intensity shower rain - 521 : CLIMACON['showers'], // shower rain - 522 : CLIMACON['showers'], // heavy intensity shower rain - 531 : CLIMACON['showers'], // ragged shower rain -// Snow - 600 : CLIMACON['snow'], // light snow - 601 : CLIMACON['snow'], // snow - 602 : CLIMACON['snow'], // heavy snow - 611 : CLIMACON['sleet'], // sleet - 612 : CLIMACON['sleet'], // shower sleet - 615 : CLIMACON['snow'], // light rain and snow - 616 : CLIMACON['snow'], // rain and snow - 620 : CLIMACON['snow'], // light shower snow - 621 : CLIMACON['snow'], // shower snow - 622 : CLIMACON['snow'], // heavy shower snow -// Atmosphere - 701 : CLIMACON['haze'], // mist - 711 : CLIMACON['haze'], // smoke - 721 : CLIMACON['haze'], // haze - 731 : CLIMACON['haze'], // Sand/Dust Whirls - 741 : CLIMACON['fog'], // Fog - 751 : CLIMACON['haze'], // sand - 761 : CLIMACON['haze'], // dust - 762 : CLIMACON['haze'], // VOLCANIC ASH - 771 : CLIMACON['wind'], // SQUALLS - 781 : CLIMACON['tornado'], // TORNADO -// Clouds - 800 : CLIMACON['sun'], // sky is clear - 801 : CLIMACON['cloud'], // few clouds - 802 : CLIMACON['cloud'], // scattered clouds - 803 : CLIMACON['cloud'], // broken clouds - 804 : CLIMACON['cloud'], // overcast clouds -// Extreme - 900 : CLIMACON['tornado'], // tornado - 901 : CLIMACON['tornado'], // tropical storm - 902 : CLIMACON['tornado'], // hurricane - 903 : CLIMACON['temp_low'], // cold - 904 : CLIMACON['temp_high'], // hot - 905 : CLIMACON['wind'], // windy - 906 : CLIMACON['hail'], // hail -// Additional - 950 : CLIMACON['set'], // Setting - 951 : CLIMACON['sun'], // Calm - 952 : CLIMACON['sun'], // Light breeze - 953 : CLIMACON['sun'], // Gentle Breeze - 954 : CLIMACON['sun'], // Moderate breeze - 955 : CLIMACON['sun'], // Fresh Breeze - 956 : CLIMACON['wind'], // Strong breeze - 957 : CLIMACON['wind'], // High wind, near gale - 958 : CLIMACON['wind'], // Gale - 959 : CLIMACON['wind'], // Severe Gale - 960 : CLIMACON['lightning'], // Storm - 961 : CLIMACON['lightning'], // Violent Storm - 962 : CLIMACON['tornado'], // Hurricane -}; - -var YWclimacon= { - 0 : CLIMACON['tornado'], //tornado - 1 : CLIMACON['tornado'], //tropical storm - 2 : CLIMACON['tornado'], //hurricane - 3 : CLIMACON['lightning'], //severe thunderstorms - 4 : CLIMACON['lightning'], //thunderstorms - 5 : CLIMACON['sleet'], //mixed rain and snow - 6 : CLIMACON['sleet'], //mixed rain and sleet - 7 : CLIMACON['sleet'], //mixed snow and sleet - 8 : CLIMACON['hail'], //freezing drizzle - 9 : CLIMACON['drizzle'], //drizzle - 10 : CLIMACON['hail'], //freezing rain - 11 : CLIMACON['showers'], //showers - 12 : CLIMACON['showers'], //showers - 13 : CLIMACON['snow'], //snow flurries - 14 : CLIMACON['snow'], //light snow showers - 15 : CLIMACON['snow'], //blowing snow - 16 : CLIMACON['snow'], //snow - 17 : CLIMACON['hail'], //hail - 18 : CLIMACON['sleet'], //sleet - 19 : CLIMACON['haze'], //dust - 20 : CLIMACON['fog'], //foggy - 21 : CLIMACON['haze'], //haze - 22 : CLIMACON['haze'], //smoky - 23 : CLIMACON['wind'], //blustery - 24 : CLIMACON['wind'], //windy - 25 : CLIMACON['temp_low'], //cold - 26 : CLIMACON['cloud'], //cloudy - 27 : CLIMACON['cloud_night'], //mostly cloudy (night) - 28 : CLIMACON['cloud_day'], //mostly cloudy (day) - 29 : CLIMACON['cloud_night'], //partly cloudy (night) - 30 : CLIMACON['cloud_day'], //partly cloudy (day) - 31 : CLIMACON['moon'], //clear (night) - 32 : CLIMACON['sun'], //sunny - 33 : CLIMACON['moon'], //fair (night) - 34 : CLIMACON['sun'], //fair (day) - 35 : CLIMACON['hail'], //mixed rain and hail - 36 : CLIMACON['temp_high'], //hot - 37 : CLIMACON['lightning'], //isolated thunderstorms - 38 : CLIMACON['lightning'], //scattered thunderstorms - 39 : CLIMACON['lightning'], //scattered thunderstorms - 40 : CLIMACON['showers'], //scattered showers - 41 : CLIMACON['snow'], //heavy snow - 42 : CLIMACON['snow'], //scattered snow showers - 43 : CLIMACON['snow'], //heavy snow - 44 : CLIMACON['cloud'], //partly cloudy - 45 : CLIMACON['lightning'], //thundershowers - 46 : CLIMACON['snow'], //snow showers - 47 : CLIMACON['lightning'], //isolated thundershowers - 3200 : CLIMACON['cloud_down'], //not available -}; - -var options = JSON.parse(localStorage.getItem('timely_options')); -//console.log('read options: ' + JSON.stringify(options)); -//if (options === null) options = { "default" : "value", "foo" : "bar"}; - -function getWeatherFromLatLong(latitude, longitude) { - var response; - var woeid = -1; - var query = encodeURI("select woeid from geo.placefinder where text=\""+latitude+","+longitude + "\" and gflags=\"R\""); - var url = "http://query.yahooapis.com/v1/public/yql?q=" + query + "&format=json"; - var req = new XMLHttpRequest(); - req.open('GET', url, true); - req.onload = function(e) { - if (req.readyState == 4) { - if (req.status == 200) { - response = JSON.parse(req.responseText); - if (response) { - woeid = response.query.results.Result.woeid; - getWeatherFromWoeid(woeid); - } - } else { - console.log("Error fetching woeid for " + url); - } - } - } - req.send(null); -} - -function getWeatherFromLocation(location_name) { - var response; - var woeid = -1; - var query = encodeURI("select woeid from geo.places(1) where text=\"" + location_name + "\""); - var url = "http://query.yahooapis.com/v1/public/yql?q=" + query + "&format=json"; - var req = new XMLHttpRequest(); - req.open('GET', url, true); - req.onload = function(e) { - if (req.readyState == 4) { - if (req.status == 200) { - // console.log(req.responseText); - response = JSON.parse(req.responseText); - if (response) { - woeid = response.query.results.place.woeid; - getWeatherFromWoeid(woeid); - } - } else { - console.log("Error fetching woeid for " + url); - } - } - } - req.send(null); -} - -function getWeatherFromWoeid(woeid) { - if (weatherFormat === 1) { units = "metric"; } - var query = encodeURI("select item.condition from weather.forecast where woeid = " + woeid + - " and u = " + (weatherFormat ? "\"c\"" : "\"f\"")); - var url = "http://query.yahooapis.com/v1/public/yql?q=" + query + "&format=json"; - - var response; - var req = new XMLHttpRequest(); - req.open('GET', url, true); - req.onload = function(e) { - if (req.readyState == 4) { - if (req.status == 200) { - response = JSON.parse(req.responseText); - if (response) { - var condition = response.query.results.channel.item.condition; - temperature = condition.temp; - icon = YWclimacon[condition.code]; - console.log("YW Weather: " + temperature + "; " + icon + " = " + condition.text); - sendWeather(Number(temperature), icon); - } - } else { - console.log("Error"); - } - } - } - req.send(null); -} -function sendWeather(temp, cond_icon) { - if (isItNight() && cond_icon == CLIMACON['sun']) { cond_icon = getMoonIcon(); } - if (cond_icon == CLIMACON['moon']) { cond_icon = getMoonIcon(); } - console.log('Sending Weather: ' + temp + ' ' + cond_icon); - Pebble.sendAppMessage({ +function sendWeather(temp, cond_icon, city, lat, lon) { + // Day/night (and the clear-night moon glyph) are already resolved by the + // weather mapping using the provider's is_day flag. + console.log('Sending Weather: ' + temp + ' ' + cond_icon + ' ' + (city || '')); + var msg = { message_type: 106, weather_temp: temp, weather_cond: cond_icon, - }); + weather_city: city || '', + }; + // Coordinates power the sunrise/sunset complications and the Auto theme. + if (lat != null && lon != null) { + msg.weather_lat = lat.toFixed(2); + msg.weather_lon = lon.toFixed(2); + } + Pebble.sendAppMessage(msg); } var locationOptions = { "timeout": 15000, "maximumAge": 60000, "enableHighAccuracy": false }; // 15 second timeout, allow 1 min cached @@ -309,29 +109,27 @@ var locationWatcher; var lastCoordinates; var weatherFormat; +// Report the phone's battery level to the watch (Web Battery API, where the +// runtime exposes it; silently skipped otherwise). +function updatePhoneBattery() { + if (!navigator.getBattery) { return; } + navigator.getBattery().then(function (b) { + Pebble.sendAppMessage({ message_type: 110, phone_battery: Math.round(b.level * 100) }); + }).catch(function () {}); +} + Pebble.addEventListener("ready", function (e) { -// console.log("Connect! " + e.ready); -// locationWatcher = window.navigator.geolocation.watchPosition(weatherLocationSuccess, locationError, locationOptions); -// navigator.geolocation.clearWatch(locationWatcher); getWatchVersion(); + updatePhoneBattery(); + setInterval(updatePhoneBattery, 30 * 60 * 1000); // refresh every 30 min }); Pebble.addEventListener("showConfiguration", function () { - //console.log("Configuration window launching..."); - var baseURL, pebtok, nocache; - baseURL = 'http://www.cyn.org/pebble/timely/'; - pebtok = '&pat=' + Pebble.getAccountToken(); - nocache = '&_=' + new Date().getTime(); - if (window.localStorage.getItem("timely_options") !== null) { - options = JSON.parse(window.localStorage.timely_options); - } - if (window.localStorage.getItem("version_config") !== null) { - Pebble.openURL(baseURL + window.localStorage.version_config + ".php?" + pebtok + nocache); - console.log(baseURL + window.localStorage.version_config + ".php?" + pebtok + nocache); - } else { // in case we never received the message / new install - Pebble.openURL(baseURL + "2.3.0.php?" + pebtok + nocache); - console.log(baseURL + "2.3.0.php?" + pebtok + nocache); - } + var current = {}; + try { current = JSON.parse(localStorage.getItem("timely_settings") || "{}"); } catch (err) {} + var html = buildConfigPage(CONFIG_SPEC, current); + // Self-contained page, no server: the watch hands the whole page to the phone. + Pebble.openURL("data:text/html," + encodeURIComponent(html)); }); function getWatchVersion() { @@ -404,63 +202,65 @@ Pebble.addEventListener("appmessage", function (e) { } }); -function fetchOWMWeather(latitude, longitude) { - var response; +// Map a WMO weather code (Open-Meteo) to a climacons glyph; day/night aware. +function wmoToClimacon(code, isDay) { + if (code === 0) { return isDay ? CLIMACON['sun'] : getMoonIcon(); } // clear + if (code === 1 || code === 2) { return isDay ? CLIMACON['cloud_day'] : CLIMACON['cloud_night']; } // mainly clear / partly cloudy + if (code === 3) { return CLIMACON['cloud']; } // overcast + if (code === 45 || code === 48) { return isDay ? CLIMACON['fog_day'] : CLIMACON['fog_night']; } // fog + if (code >= 51 && code <= 57) { return CLIMACON['drizzle']; } // drizzle (incl. freezing) + if (code >= 61 && code <= 65) { return CLIMACON['rain']; } // rain + if (code === 66 || code === 67) { return CLIMACON['sleet']; } // freezing rain + if (code >= 71 && code <= 77) { return CLIMACON['snow']; } // snow + if (code >= 80 && code <= 82) { return CLIMACON['showers']; } // rain showers + if (code === 85 || code === 86) { return CLIMACON['flurries']; } // snow showers + if (code >= 95) { return CLIMACON['lightning']; } // thunderstorm + return CLIMACON['cloud']; +} + +// Reverse-geocode coordinates to a city name (free, no key, https). Best-effort: +// calls back with "" on any failure so weather still updates. +function reverseGeocode(latitude, longitude, cb) { var req = new XMLHttpRequest(); -//http://api.openweathermap.org/data/2.5/weather?lat=35.8415051596573&lon=-78.55771335780486&cnt=1&units=metric -//http://api.openweathermap.org/data/2.5/weather?lat=35.8415051596573&lon=-78.55771335780486&cnt=1&units=imperial - var units = "imperial"; - if (weatherFormat === 1) { units = "metric"; } - req.open('GET', "http://api.openweathermap.org/data/2.5/weather?" + - "lat=" + latitude + "&lon=" + longitude + "&appid=fdc43ca42ea6a45d9c73a810f840aa55" + "&cnt=1" + "&units=" + units, true); - req.onload = function(e) { - if (req.readyState == 4) { - if(req.status == 200) { - //console.log('Weather Response: ' + req.responseText); - response = JSON.parse(req.responseText); - var temp, temp_min, temp_max, icon, city; - if (response && response.weather && response.weather.length > 0) { - var weatherResult = response; - temp = Math.round(weatherResult.main.temp); - temp_min = Math.round(weatherResult.main.temp_min); - temp_max = Math.round(weatherResult.main.temp_max); - cond_main = weatherResult.weather[0].main; - cond_desc = weatherResult.weather[0].description; - cond_icon = OWMclimacon[weatherResult.weather[0].id]; - city = weatherResult.name; - - console.log("OWM Weather: " + temp + "; " + cond_icon + " = " + cond_main + ", " + cond_desc); -/* - console.log('temp: ' + temp); - console.log('temp_min: ' + temp_min); - console.log('temp_max: ' + temp_max); - console.log('city: ' + city); - console.log('cond: ' + cond_main); - console.log('cond_desc: ' + cond_desc); - console.log('cond_icon: ' + cond_icon); - weather_temp_min: temp_min, - weather_temp_max: temp_max, -*/ - sendWeather(temp, cond_icon); - } else { - for(var i in dataObj) { - console.log('dO:' + i + ' --- ' + dataObj[i]); - } - } + req.open('GET', "https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=" + + latitude + "&longitude=" + longitude + "&localityLanguage=en", true); + req.timeout = 10000; + req.onload = function() { + var city = ""; + try { var r = JSON.parse(req.responseText); city = r.city || r.locality || ""; } catch (e) {} + cb(city); + }; + req.onerror = function() { cb(""); }; + req.ontimeout = function() { cb(""); }; + req.send(null); +} - } else { - console.log("Error"); - } - } - } +function fetchWeather(latitude, longitude) { + // Open-Meteo: https, no API key, returns is_day for reliable day/night. + var unit = (weatherFormat === 1) ? "&temperature_unit=fahrenheit" : ""; + var req = new XMLHttpRequest(); + req.open('GET', "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + + "&longitude=" + longitude + "¤t=temperature_2m,weather_code,is_day" + unit, true); + req.timeout = 15000; + req.onload = function() { + if (req.readyState !== 4) { return; } + if (req.status !== 200) { console.log("Weather HTTP " + req.status); return; } + try { + var r = JSON.parse(req.responseText); + var temp = Math.round(r.current.temperature_2m); + var icon = wmoToClimacon(r.current.weather_code, r.current.is_day === 1); + console.log("Open-Meteo: " + temp + "; code " + r.current.weather_code + "; day " + r.current.is_day); + reverseGeocode(latitude, longitude, function(city) { sendWeather(temp, icon, city, latitude, longitude); }); + } catch (e) { console.log("Weather parse error: " + e); } + }; + req.onerror = function() { console.log("Weather request failed"); }; + req.ontimeout = function() { console.log("Weather request timed out"); }; req.send(null); } function weatherLocationSuccess(pos) { lastCoordinates = pos.coords; - //console.log('Weather: location found (' + lastCoordinates.latitude + ', ' + lastCoordinates.longitude + '): '); - fetchOWMWeather(lastCoordinates.latitude, lastCoordinates.longitude); // OWM: Open Weather Map - //getWeatherFromLatLong(lastCoordinates.latitude, lastCoordinates.longitude); // YW: Yahoo Weather + fetchWeather(lastCoordinates.latitude, lastCoordinates.longitude); } function locationError(err) { @@ -509,34 +309,27 @@ V = waning crescent 0.25 + return moon; } -function b64_to_utf8( str ) { - return decodeURIComponent(escape(base64.decode( str.replace(/ +/g, '+') ))); -} - Pebble.addEventListener("webviewclosed", function (e) { - //console.log("Configuration closed"); - //console.log("Response = " + e.response.length + " " + e.response); - if (e.response !== undefined && e.response !== '' && e.response !== 'CANCELLED') { // user clicked Save/Submit, not Cancel/Done - var options, web; - options = JSON.parse(b64_to_utf8(e.response)); - window.localStorage.timely_options = JSON.stringify(options); - web = options.web; - delete options.web; // remove the 'web' object from our response, which has preferences such as language... - options[15] = web.lang; // re-inject the language - if (options[10] === 1) { // debugging is on... - console.log("Options = " + JSON.stringify(options)); + if (!e || !e.response) { return; } // user cancelled + + // The page sends only the keys that changed. Merge them into the stored + // settings (don't overwrite) so the config page keeps the full picture and + // doesn't show untouched settings as reset on the next open. + var dict; + try { dict = JSON.parse(decodeURIComponent(e.response)); } catch (err) { return; } + var stored = {}; + try { stored = JSON.parse(localStorage.getItem("timely_settings") || "{}"); } catch (err) {} + for (var k in dict) { stored[k] = dict[k]; } + localStorage.setItem("timely_settings", JSON.stringify(stored)); + + Pebble.sendAppMessage(dict, + function (e) { + console.log("Delivered config with transactionId=" + e.data.transactionId); + }, + function (e) { + console.log("Unable to deliver config: " + e.data.error.message); } - Pebble.sendAppMessage(options, - function (e) { - console.log("Successfully delivered message with transactionId=" + e.data.transactionId); - }, - function (e) { - console.log("Unable to deliver message with transactionId=" + e.data.transactionId + " Error is: " + e.data.error.message); - } - ); - } else if (e.response === 'CANCELLED') { - console.log("Android misbehaving on save due to embedded space in e.response... ignoring"); - } + ); }); @@ -625,7 +418,7 @@ function getSunCoords(d) { } -var SunCalc = {}; +SunCalc = {}; // calculates sun position for a given date and latitude/longitude diff --git a/src/js/config.js b/src/js/config.js new file mode 100644 index 0000000..6f5f9f8 --- /dev/null +++ b/src/js/config.js @@ -0,0 +1,179 @@ +// Declarative spec for the offline settings page (see configpage.js). +// Each field's `key` matches a messageKey in package.json. Numeric keys are read +// by the watch as uint8; text keys (language + translations) as strings. + +var VIBES = [ + ['None', 0], ['1x', 1], ['2x', 2], ['3x', 3], + ['Long', 4], ['Min', 5], ['Min 2', 6], ['Ow', 7] +]; + +// Complication menu shared by the two slots above the calendar. +// Values must match update_slot_text() in Timely.c. +// [label, value]; value must match update_slot_text() in Timely.c. Displayed +// alphabetically (— pinned first), so new entries can be appended in any order. +var SLOTS = [ + ['—', 0], ['Day', 1], ['Month', 2], ['Week', 3], ['Timezone', 4], ['AM/PM', 5], + ['Day of year', 6], ['Days left in year', 7], ['Day of year / left', 8], + ['Seconds', 9], ['Location', 10], ['Sunrise', 11], ['Sunset', 12], ['Moon phase', 13], + ['2nd time zone', 14], + ['Watch battery', 15], ['Phone battery', 16], ['Bluetooth', 17], ['Date', 18] +]; +SLOTS = [SLOTS[0]].concat(SLOTS.slice(1).sort(function (a, b) { + return a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : 0); +})); + +// UTC offsets for the second time zone (whole hours). +var TZ_OFFSETS = (function () { + var o = []; + for (var h = -12; h <= 14; h++) { + o.push([h === 0 ? 'UTC' : 'UTC' + (h > 0 ? '+' + h : h), h]); + } + return o; +})(); + +// Date formats; values must match update_date_text() in Timely.c. 255 = custom. +var DATE_FORMATS = [ + ['MMMM DD, YYYY', 0], ['MMMM DD, ’YY', 1], ['Mmm DD, YYYY', 2], ['Mmm DD, ’YY', 3], + ['D MMMM YYYY', 11], ['D MMMM ’YY', 12], ['D Mmm YYYY', 13], ['D Mmm ’YY', 14], + ['MM/DD/YYYY', 197], ['DD/MM/YYYY', 217], ['DD.MM.YYYY', 215], ['YYYY-MM-DD', 236], + ['DD-MM-YYYY', 216], ['MM-DD-YYYY', 196], ['YYYY/MM/DD', 237], ['YYYYMMDD', 239], + ['Custom (strftime)', 255] +]; + +// Build a list of text fields: keys[i] -> label[i] with default defs[i]. +function textFields(keys, labels, defs, max) { + var out = []; + for (var i = 0; i < keys.length; i++) { + out.push({ key: keys[i], label: labels[i], type: 'text', def: defs[i], max: max }); + } + return out; +} + +var DOW = ['sunday', 'monday', 'tuesday', 'wedsday', 'thursday', 'friday', 'saturday']; +var DOW_LABEL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +var DOW_ABBR = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; +var MON = ['january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december']; +var MON_LABEL = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; +var MON_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// Editable translation strings. The language selector (see configpage.js) fills +// these from a built-in table; they are only shown for manual editing when the +// language is set to Custom (langCustom flag). +var translations = [ + { key: 'trans_connected', label: 'Connected', type: 'text', def: 'Linked', max: 9 }, + { key: 'trans_disconnected', label: 'Disconnected', type: 'text', def: 'NOLINK', max: 9 }, + { key: 'trans_time_am', label: 'AM', type: 'text', def: 'AM', max: 6 }, + { key: 'trans_time_pm', label: 'PM', type: 'text', def: 'PM', max: 6 } +] + .concat(textFields(DOW.map(function (d) { return 'trans_abbr_' + d; }), + DOW_LABEL.map(function (l) { return l + ' (abbr)'; }), DOW_ABBR, 2)) + .concat(textFields(DOW.map(function (d) { return 'trans_' + d; }), DOW_LABEL, DOW_LABEL, 12)) + .concat(textFields(MON.map(function (m) { return 'trans_abbr_' + m; }), + MON_LABEL.map(function (l) { return l + ' (abbr)'; }), MON_ABBR, 3)) + .concat(textFields(MON.map(function (m) { return 'trans_' + m; }), MON_LABEL, MON_LABEL, 11)); +translations.forEach(function (f) { f.langCustom = true; }); + +module.exports = [ + { + title: 'Appearance', + fields: [ + { key: 'theme', label: 'Theme', type: 'select', def: 1, + options: [['Mono', 0], ['Functional', 1], ['Minimal', 2], ['Vibrant', 3]] }, + { key: 'theme_mode', label: 'Mode', type: 'select', def: 1, + note: 'Auto follows local sunset/sunrise.', + options: [['Light', 0], ['Dark', 1], ['Auto', 2]] }, + { key: 'style_day_inv', label: 'Highlight today', type: 'toggle', def: 1 }, + { key: 'style_grid', label: 'Calendar grid background', type: 'toggle', def: 1 } + ] + }, + { + title: 'Clocks & Date', + fields: [ + { key: 'intl_fmt_date', label: 'Date format', type: 'select', def: 0, options: DATE_FORMATS }, + { key: 'strftime_format', label: 'Custom format', type: 'text', def: '%Y-%m-%d', max: 31, + showWhen: { key: 'intl_fmt_date', val: 255 }, + note: 'strftime pattern, e.g. %a %d %b. Used when Date format is Custom.' } + ] + }, + { + title: 'Complications', + note: 'Each row has a left and a right slot. Set one slot for a full-width complication, both for two side-by-side; leave both empty (—) to hide the row and give the space to the clock and calendar.', + fields: [ + { key: 'slot_stat_l', label: 'Status bar — left', type: 'select', def: 16, options: SLOTS }, + { key: 'slot_stat_r', label: 'Status bar — right', type: 'select', def: 15, options: SLOTS }, + { key: 'slot_ctr_l', label: 'Above time — left', type: 'select', def: 18, options: SLOTS }, + { key: 'slot_ctr_r', label: 'Above time — right', type: 'select', def: 0, options: SLOTS }, + { key: 'style_week', label: 'Above calendar — left', type: 'select', def: 0, options: SLOTS }, + { key: 'style_am_pm', label: 'Above calendar — right', type: 'select', def: 0, options: SLOTS }, + { key: 'batt_style', label: 'Battery style', type: 'select', def: 2, + options: [['Bar (fills) with %', 0], ['Text %', 1], ['Icon + %', 2], ['Bar + icon', 3]], + note: 'How the Watch/Phone battery complications are drawn.' }, + { key: 'intl_fmt_week', label: 'Week numbering', type: 'select', def: 0, + options: [['ISO 8601', 0], ['Sun 1st of W1', 1], ['Mon 1st of W1', 2]], + note: 'For the "Week" complication.' }, + { key: 'clock2_tz', label: '2nd time zone offset', type: 'select', def: 0, options: TZ_OFFSETS, + note: 'For the "2nd time zone" complication.' } + ] + }, + { + title: 'Calendar', + fields: [ + { key: 'intl_dowo', label: 'Start of week', type: 'select', def: 0, + options: [['Sunday', 0], ['Monday', 1], ['Tuesday', 2], ['Wednesday', 3], + ['Thursday', 4], ['Friday', 5], ['Saturday', 6]] }, + { key: 'cal_week_pattern', label: 'Weeks shown', type: 'select', def: 0, + options: [['Previous + next', 0], ['Last two weeks', 1], ['Next two weeks', 2]] } + ] + }, + { + title: 'Weather', + open: false, + fields: [ + { key: 'weather_fmt', label: 'Units', type: 'select', def: 0, + options: [['Celsius', 0], ['Fahrenheit', 1]] }, + { key: 'weather_update', label: 'Update every', type: 'select', def: 15, + options: [['Off', 0], ['15 min', 15], ['30 min', 30], ['60 min', 60]] } + ] + }, + { + title: 'Status bar', + open: false, + fields: [ + { key: 'show_stat_bar', label: 'Show status bar', type: 'select', def: 1, + options: [['Never', 0], ['Always', 1], ['When battery low', 2]] }, + { key: 'show_stat_batt', label: 'Low battery threshold (%)', type: 'number', def: 20, + min: 0, max: 100, note: 'Used when "When battery low" is selected.' } + ] + }, + { + title: 'Vibration', + open: false, + fields: [ + { type: 'vibe-sched', patterns: VIBES }, + { key: 'vibe_pat_disconnect', label: 'On disconnect', type: 'select', def: 2, options: VIBES }, + { key: 'vibe_pat_connect', label: 'On reconnect', type: 'select', def: 0, options: VIBES } + ] + }, + { + title: 'Do Not Disturb', + open: false, + fields: [ + { type: 'dnd-sched' } + ] + }, + { + title: 'Language', + open: false, + fields: [{ type: 'lang-sel' }].concat(translations) + }, + { + title: 'Advanced', + open: false, + fields: [ + { key: 'debugging_on', label: 'Debug logging', type: 'toggle', def: 0 }, + { key: 'debuglang_on', label: 'Translation debug logging', type: 'toggle', def: 0 } + ] + } +]; diff --git a/src/js/configpage.js b/src/js/configpage.js new file mode 100644 index 0000000..22afe3b --- /dev/null +++ b/src/js/configpage.js @@ -0,0 +1,252 @@ +'use strict'; + +// Builds the offline settings page as a self-contained HTML string from the +// config spec. No external CSS/JS/host: app.js serves it via a data: URI, so it +// works with no network. On submit the page redirects to `return_to` (provided +// by the emulator as a query param) or, on a real watch, to pebblejs://close#. +// +// Field types: select (int), toggle (0/1), number (int), text (string), +// time (HH:MM stored as a 10-minute increment 0-143, h*6 + floor(m/10)). + +function esc(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function pad2(n) { return ('0' + n).slice(-2); } +function incrToTime(incr) { + incr = Number(incr) || 0; + return pad2(Math.floor(incr / 6)) + ':' + pad2((incr % 6) * 10); +} + +// Built-in translation tables (keyed by message key) for the language selector. +function langTable(months, abbrM, days, abbrD, conn, disc) { + var DOW = ['sunday', 'monday', 'tuesday', 'wedsday', 'thursday', 'friday', 'saturday']; + var MON = ['january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december']; + var t = { trans_time_am: 'AM', trans_time_pm: 'PM', trans_connected: conn, trans_disconnected: disc }; + for (var i = 0; i < 12; i++) { t['trans_' + MON[i]] = months[i]; t['trans_abbr_' + MON[i]] = abbrM[i]; } + for (var j = 0; j < 7; j++) { t['trans_' + DOW[j]] = days[j]; t['trans_abbr_' + DOW[j]] = abbrD[j]; } + return t; +} +var LANGS = { + EN: langTable( + ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], 'Linked', 'NOLINK'), + IT: langTable( + ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'], + ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'], + ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'], + ['Do', 'Lu', 'Ma', 'Me', 'Gi', 'Ve', 'Sa'], 'Connesso', 'Assente') +}; +var LANG_OPTIONS = [['Follow system', 'system'], ['English', 'EN'], ['Italiano', 'IT'], ['Custom', 'custom']]; + +function selOptions(options, val) { + var s = ''; + for (var i = 0; i < options.length; i++) { + s += ''; + } + return s; +} + +// A "scheduled" control: a mode select plus a From/To window (shown only in the +// time-period mode). The window inputs carry no data-key; app.js derives the +// watch keys from them in augment() so equal From/To can be rejected. `id` is +// "vibe" or "dnd" — augment() knows how each maps to message keys. +function renderSched(id, label, modes, modeVal, fromVal, toVal, note) { + var h = ''; + if (note) { h += '

' + esc(note) + '

'; } + h += '
' + + '' + + '
'; + return h; +} + +function renderField(f, current) { + if (f.type === 'vibe-sched') { + var vs = Number((current && current.vibe_start) || 0); + var ve = Number((current && current.vibe_stop) || 0); + // mode comes from vibe_days; fall back to deriving it from older saves. + var vmode = (current && current.vibe_days != null) ? Number(current.vibe_days) + : (Number((current && current.vibe_hour) || 0) === 0 ? 0 : (vs === ve ? 1 : 2)); + var vpat = Number((current && current.vibe_hour) || 0); + if (vpat <= 1) { vpat = 2; } // 0/1 came from the old on-off encoding; default to 2x + var h = renderSched('vibe', 'Hourly vibration', + [['Off', 0], ['Always On', 1], ['Time Period', 2], ['Follow Do Not Disturb', 3]], vmode, vs, ve, + 'Time Period: From and To must differ. Follow DND: vibrates hourly except during Do Not Disturb.'); + h += ''; + return h; + } + if (f.type === 'dnd-sched') { + var dm = Number((current && current.dnd_noaccel) || 0); + var ds = Number((current && current.dnd_start) || 0); + var de = Number((current && current.dnd_stop) || 0); + return renderSched('dnd', 'Mode', + [['Off', 0], ['Follow watch', 1], ['Time period', 2]], dm, ds, de, + 'Follow watch uses the system Quiet Time. Time period: From and To must differ.'); + } + if (f.type === 'lang-sel') { + var lc = (current && current.language) ? String(current.language).toUpperCase() : 'EN'; + var sel = LANGS[lc] ? lc : 'custom'; // known language -> that; else Custom (keep existing strings) + var opts = ''; + for (var li = 0; li < LANG_OPTIONS.length; li++) { + opts += ''; + } + return '' + + '

Pick a language to fill the strings below, or Custom to edit them by hand.

'; + } + var cur = (current && current[f.key] != null) ? current[f.key] : f.def; + var attrs = 'data-key="' + esc(f.key) + '"'; + var control; + switch (f.type) { + case 'toggle': + control = ''; + break; + case 'number': + control = ''; + break; + case 'time': + control = ''; + break; + case 'text': + control = ''; + break; + default: // select + control = ''; + } + var row = ''; + if (f.note) { row += '

' + esc(f.note) + '

'; } + if (f.showWhen) { + row = '
' + row + '
'; + } + if (f.langCustom) { row = '
' + row + '
'; } + return row; +} + +var CSS = [ + '*{box-sizing:border-box}', + 'body{margin:0;padding:14px 14px 92px;background:#1b1b1d;color:#eee;', + 'font-family:-apple-system,Roboto,Segoe UI,Helvetica,Arial,sans-serif;-webkit-text-size-adjust:100%}', + 'h1{font-size:22px;font-weight:600;margin:4px 2px 16px}', + 'details{background:#262629;border-radius:10px;padding:0 14px;margin:0 0 12px;overflow:hidden}', + 'summary{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:#ff9f0a;', + 'font-weight:600;padding:13px 0;cursor:pointer;list-style:none}', + 'summary::-webkit-details-marker{display:none}', + 'summary:after{content:"+";float:right;color:#888;font-weight:400}', + 'details[open] summary:after{content:"\\2212"}', + '.row{display:flex;align-items:center;justify-content:space-between;gap:12px;', + 'padding:11px 0;border-top:1px solid #333;font-size:16px}', + 'select,input[type=number],input[type=text],input[type=time]{font-size:16px;background:#333;color:#eee;', + 'border:1px solid #454547;border-radius:7px;padding:8px 9px;max-width:55%}', + 'input[type=text]{max-width:48%}', + 'input[type=checkbox]{width:26px;height:26px;accent-color:#ff9f0a;flex:none}', + '.note{font-size:12px;color:#9a9a9a;margin:2px 0 8px}', + '.bar{position:fixed;left:0;right:0;bottom:0;display:flex;gap:10px;padding:11px 14px;', + 'background:#1b1b1de6;border-top:1px solid #333}', + 'button{flex:1;font-size:17px;font-weight:600;border:0;border-radius:8px;padding:13px;color:#fff}', + '#cancel{background:#3a3a3d}#save{background:#ff9f0a;color:#1b1b1d}' +].join(''); + +function buildConfigPage(spec, current) { + current = current || {}; + var body = ''; + // Baseline = the value each control starts at; on submit we send only the keys + // that actually changed, keeping the pebblejs://close payload small (a full + // dump of every setting exceeds the close-URL length and gets dropped). + var baseline = {}; + for (var s = 0; s < spec.length; s++) { + var sec = spec[s]; + body += '' + esc(sec.title) + ''; + for (var i = 0; i < sec.fields.length; i++) { + var f = sec.fields[i]; + body += renderField(f, current); + if (f.key) { baseline[f.key] = (current[f.key] != null) ? current[f.key] : f.def; } + } + body += ''; + } + // Keys derived from the scheduled (vibe/dnd) controls also need a baseline. + var derived = ['vibe_days', 'vibe_hour', 'vibe_start', 'vibe_stop', 'dnd_noaccel', 'dnd_start', 'dnd_stop', 'language']; + for (var d2 = 0; d2 < derived.length; d2++) { + var dflt = (derived[d2] === 'language') ? 'EN' : 0; + baseline[derived[d2]] = (current[derived[d2]] != null) ? current[derived[d2]] : dflt; + } + var script = + // Read return_to from the full href: data:/file: URIs do not populate + // location.search, but the emulator appends ?return_to= and the encoded page + // body has no literal ? or &, so the match here is unambiguous. + 'function qp(n,d){var m=(location.href||"").match(new RegExp("[?&]"+n+"=([^&#]*)"));' + + 'return m?decodeURIComponent(m[1]):d;}' + + 'var RET=qp("return_to","pebblejs://close#");' + + 'var BASELINE=' + JSON.stringify(baseline) + ';' + + 'var LANGS=' + JSON.stringify(LANGS) + ';' + + // Language selector: resolve the chosen code, fill the (hidden) translation + // fields from the table, and toggle the Custom editor. + 'function langResolve(v){if(v==="system"){var n=(navigator.language||"en").slice(0,2).toUpperCase();return LANGS[n]?n:"EN";}if(v==="custom")return (BASELINE.language||"EN");return v;}' + + 'function langFill(code){var t=LANGS[code];if(!t)return;for(var k in t){var e=document.querySelector(\'[data-key="\'+k+\'"]\');if(e)e.value=t[k];}}' + + 'function langCustomShow(on){var cs=document.querySelectorAll(".langcustom");for(var i=0;i' + + '' + + 'TimelyNG settings' + + '

TimelyNG

' + body + + '
' + + '
' + + ' - - - -
-
-

Configuration

-
- -
- -
-
-

General

- -
- Theme: - - - - -
- -
- Hourly Vibration: - - - - - - - - - - - - - - - - -
- -
- Disconnect Vibration: - - - - - - - - - - - - - - - - -
- -
- Connect Vibration: - - - - - - - - - - - - - - - - -
- -
-
-

Translations

- -
-

Status:

-
- - -
-
- - -
-
- -
-

Days of Week (abbreviated):

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

Months:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-

Days of Week:

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-

Clock

- -
- Date Format: - - - - -
- -
- Below Time, Left: - - - - - - - - -
- -
- Below Time, Middle: - - - - - - - - - - - - -
- -
- Below Time, Right: - - - - - - - - -
- -
- Week Format: - - - - - - -
- -
-
-

Calendar

- -
- Invert Today: - - - - -
- -
- Grid: - - - - -
- -
- Start of Week: - - - - - - - - - - - - - - -
- -
-
-
-
-
-
-
-
-
-
-
- - - diff --git a/src/layout.c b/src/layout.c new file mode 100644 index 0000000..68a8f65 --- /dev/null +++ b/src/layout.c @@ -0,0 +1,71 @@ +#include "layout.h" + +// Adaptive vertical layout. Three optional rows — status bar (TOP), the band +// above the time (CENTER), and the band above the calendar (BOTTOM) — each take +// a fixed slice only when enabled. Whatever is left is shared between the +// time/weather band and the calendar, so disabling a row makes the rest grow. +#define STATUSBAR_H 24 +#define CENTER_H 26 +#define BOTTOM_H 22 +#define CAL_COLS 7 +#define CAL_GAP 1 +#define CAL_LEFT 2 + +static int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } + +TimelyLayout layout_compute_rows(int width, int height, int has_top, int has_center, int has_bottom) { + TimelyLayout L; + int stat_h = has_top ? STATUSBAR_H : 0; + int center_h = has_center ? CENTER_H : 0; + int bottom_h = has_bottom ? BOTTOM_H : 0; + + // Space left for the time/weather band + the calendar after the fixed rows. + int avail = height - stat_h - center_h - bottom_h; + // The clock takes ~42% of that (calendar is the protagonist and keeps the + // rest); clamp so the clock font has room without dominating tall screens. + int time_h = clampi((avail * 42) / 100, 40, 92); + int cal_h = avail - time_h; + + int slot_top_h = center_h + time_h + bottom_h; + + L.statusbar = (LayoutRect){ 0, 0, width, stat_h }; + L.slot_top = (LayoutRect){ 0, stat_h, width, slot_top_h }; + L.slot_bot = (LayoutRect){ 0, stat_h + slot_top_h, width, cal_h }; + + // statusbar internals (battery right-aligned; icons mirror the 144x168 look) + L.battery = (LayoutRect){ width - 48, 4, 44, 15 }; + L.bt_icon_x = -2; L.bt_icon_y = 2; + L.chrg_icon_x = width - 68; L.chrg_icon_y = 2; + + // Bands inside slot_top, stacked: CENTER (top), time/weather, BOTTOM (bottom). + // All relative to slot_top's origin. + L.clock_date = (LayoutRect){ 2, 0, width - 4, center_h }; // CENTER row band + L.clock_time = (LayoutRect){ 0, center_h, width - 2, time_h }; // time/weather band + L.subtext_top = center_h + time_h; // BOTTOM row top + + // calendar grid inside slot_bot: 7 columns spanning the width, header + 3 weeks. + L.cal_cols = CAL_COLS; + L.cal_gap = CAL_GAP; + L.cal_left = CAL_LEFT; + L.cal_cell_w = (width - 2 * CAL_LEFT) / CAL_COLS; + L.cal_cell_h = cal_h / 4; + + return L; +} + +// Back-compat default: all three rows present (used by host tests). +TimelyLayout layout_compute(int width, int height) { + return layout_compute_rows(width, height, 1, 1, 1); +} + +ClockFont clock_font_for(int width, int band_h) { + if (width >= 180 && band_h >= 60) { return CLOCK_FONT_ROBOTO_49; } + if (band_h >= 52) { return CLOCK_FONT_LECO_42; } + if (band_h >= 44) { return CLOCK_FONT_LECO_38; } + if (band_h >= 36) { return CLOCK_FONT_LECO_32; } + return CLOCK_FONT_LECO_28; +} + +static TimelyLayout s_current; +void layout_store(TimelyLayout l) { s_current = l; } +TimelyLayout layout_get(void) { return s_current; } diff --git a/src/layout.h b/src/layout.h new file mode 100644 index 0000000..4eb76a7 --- /dev/null +++ b/src/layout.h @@ -0,0 +1,43 @@ +#pragma once +// Pebble-free layout geometry so it can be unit-tested on the host. +typedef struct { int x, y, w, h; } LayoutRect; + +typedef struct { + LayoutRect statusbar; // top band (fixed height: BT/charge/battery) + LayoutRect slot_top; // upper half: time/date/weather + LayoutRect slot_bot; // lower half: calendar + LayoutRect battery; // right-aligned battery box (within statusbar) + int bt_icon_x, bt_icon_y; + int chrg_icon_x, chrg_icon_y; + LayoutRect clock_time; // relative to slot_top origin + LayoutRect clock_date; // relative to slot_top origin + int subtext_top; + int cal_cols; + int cal_cell_w; + int cal_cell_h; + int cal_left; + int cal_gap; +} TimelyLayout; + +// Compute the layout for a screen of width x height pixels. The _rows variant +// takes which optional rows are enabled (TOP status bar / CENTER above-time / +// BOTTOM above-calendar); disabled rows free their height to the rest. +TimelyLayout layout_compute_rows(int width, int height, int has_top, int has_center, int has_bottom); +TimelyLayout layout_compute(int width, int height); // all rows present (default) + +// Clock font choice as a pure, testable decision (the view maps it to a Pebble +// FONT_KEY). Scales with the time-band height; the big proportional Roboto is +// only for wide screens where it won't collide with the weather column. +typedef enum { + CLOCK_FONT_ROBOTO_49 = 0, // wide screens, tall band + CLOCK_FONT_LECO_42, + CLOCK_FONT_LECO_38, + CLOCK_FONT_LECO_32, + CLOCK_FONT_LECO_28, // smallest (very short band) +} ClockFont; +ClockFont clock_font_for(int width, int band_h); + +// The most recently computed layout (set by the view at window_load); read by +// components that render proportionally (calendar, etc.). +void layout_store(TimelyLayout l); +TimelyLayout layout_get(void); diff --git a/src/locale.c b/src/locale.c new file mode 100644 index 0000000..de0cc34 --- /dev/null +++ b/src/locale.c @@ -0,0 +1,21 @@ +#include "locale.h" + +static persist_months_lang s_months = { + .monthsNames = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }, +}; + +static persist_days_lang s_days = { + .DaysOfWeek = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }, +}; + +static persist_general_lang s_gen = { + .statuses = { "Linked", "NOLINK" }, + .abbrTime = { "AM", "PM" }, + .abbrDaysOfWeek = { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" }, + .abbrMonthsNames = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }, + .language = "EN", +}; + +persist_general_lang *lang_gen_get(void) { return &s_gen; } +persist_months_lang *lang_months_get(void){ return &s_months; } +persist_days_lang *lang_days_get(void) { return &s_days; } diff --git a/src/locale.h b/src/locale.h new file mode 100644 index 0000000..04f5315 --- /dev/null +++ b/src/locale.h @@ -0,0 +1,23 @@ +#pragma once +// Localization tables. State owned by locale.c; access via the *_get() +// accessors. Structs are persist-backed. + +typedef struct persist_months_lang { // 252 bytes + char monthsNames[12][21]; // 252: 10-20 UTF8 characters for each of 12 months +} __attribute__((__packed__)) persist_months_lang; + +typedef struct persist_days_lang { // 238 bytes + char DaysOfWeek[7][34]; // 238: 16-33 UTF8 characters for each of 7 weekdays +} __attribute__((__packed__)) persist_days_lang; + +typedef struct persist_general_lang { // 253 bytes + char statuses[2][26]; + char abbrTime[2][12]; + char abbrDaysOfWeek[7][6]; + char abbrMonthsNames[12][11]; + char language[3]; // 2 chars for language code (ascii) +} __attribute__((__packed__)) persist_general_lang; + +persist_general_lang *lang_gen_get(void); +persist_months_lang *lang_months_get(void); +persist_days_lang *lang_days_get(void); diff --git a/src/settings.c b/src/settings.c new file mode 100644 index 0000000..2241579 --- /dev/null +++ b/src/settings.c @@ -0,0 +1,61 @@ +#include "settings.h" + +static persist s_settings = { + .version = 12, + .inverted = 0, // no, dark + .day_invert = 1, // yes + .grid = 1, // yes + .vibe_hour = 0, // no + .dayOfWeekOffset = 0, // 0 - 6, Sun - Sat + .date_format = 0, // Month DD, YYYY + .show_am_pm = 5, // above calendar, right: AM/PM + .show_day = 0, + .show_week = 3, // above calendar, left: Week + .slot_stat_l = 16, // status bar left: phone battery + .slot_stat_r = 15, // status bar right: watch battery + .slot_ctr_l = 18, // center row left: Date (full width when alone) + .slot_ctr_r = 0, // center row right: empty (Date stays full-width/centered) + .batt_style = 2, // battery: icon + text + + .week_format = 0, // ISO 8601 + .vibe_pat_disconnect = 2, // double vibe + .vibe_pat_connect = 0, // no vibe + .strftime_format = "%Y-%m-%d", + .track_battery = 0, + .theme = 1, // Functional + .theme_mode = 1, // Dark (matches the pre-theme look) +}; + +static persist_adv_settings s_adv = { + .week_pattern = 0, + .invertStatBar = 0, + .invertTopSlot = 0, + .invertBotSlot = 0, + .showStatus = 1, + .showStatusBat = 100, + .showDate = 1, + .DND_start = 0, + .DND_stop = 0, + .dnd_mode = 0, + .vibe_hour_start = 0, + .vibe_hour_stop = 0, + .vibe_hour_days = 0, + .idle_reminder = 0, + .idle_pattern = 0, + .custom_date_fmt = { "%Y-%m-%d" }, + .idle_start = 0, + .idle_stop = 0, + .clock2_tz = 0, + .clock2_desc = { "Second Clock" }, + .weather_format = 0, + .weather_update = 15, + .weather_lat = "", + .weather_lon = "", + .clock_font = 1, + .token_type = { 0, 0 }, + .token_code = { "", "" }, + .slots = { 0, 1, 2, 3, 0, 1, 0, 1, 0, 1 } +}; + +persist *settings_get(void) { return &s_settings; } +persist_adv_settings *adv_settings_get(void) { return &s_adv; } diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..5cdb39c --- /dev/null +++ b/src/settings.h @@ -0,0 +1,63 @@ +#pragma once +#include +// User settings + advanced settings. State owned by settings.c; access via +// settings_get() / adv_settings_get(). Structs are persist-backed. + +typedef struct persist { // 18 bytes + uint8_t version; // version key + uint8_t inverted; // Invert display + uint8_t day_invert; // Invert colors on today's date + uint8_t grid; // Show the grid + uint8_t vibe_hour; // vibrate at the top of the hour? + uint8_t dayOfWeekOffset; // first day of our week + uint8_t date_format; // date format + uint8_t show_am_pm; // Above-calendar slot, right (complication content) + uint8_t show_day; // (retired middle slot) + uint8_t show_week; // Above-calendar slot, left (complication content) + uint8_t slot_stat_l; // Status bar slot, left (complication content) + uint8_t slot_stat_r; // Status bar slot, right (complication content) + uint8_t slot_ctr_l; // Center row (above time), left + uint8_t slot_ctr_r; // Center row (above time), right + uint8_t batt_style; // battery complication style: 0 bar, 1 text, 2 icon+text + uint8_t week_format; // week format (calculation, e.g. ISO 8601) + uint8_t vibe_pat_disconnect; // vibration pattern for disconnect + uint8_t vibe_pat_connect; // vibration pattern for connect + char *strftime_format; // custom date_format string (date_format = 255) + uint8_t track_battery; // track battery information + uint8_t theme; // color theme id (see theme.h) + uint8_t theme_mode; // 0 light, 1 dark, 2 auto (dark at night) +} __attribute__((__packed__)) persist; + +typedef struct persist_adv_settings { // 243 bytes + uint8_t week_pattern; // 1 byte + uint8_t invertStatBar; // 1 byte + uint8_t invertTopSlot; // 1 byte + uint8_t invertBotSlot; // 1 byte + uint8_t showStatus; // 1 byte + uint8_t showStatusBat; // 1 byte + uint8_t showDate; // 1 byte + uint8_t DND_start; // 1 byte + uint8_t DND_stop; // 1 byte + uint8_t dnd_mode; // 1 byte: 0 off, 1 follow watch Quiet Time, 2 app window + uint8_t vibe_hour_start; // 1 byte + uint8_t vibe_hour_stop; // 1 byte + uint8_t vibe_hour_days; // 1 byte + uint8_t idle_reminder; // 1 byte + uint8_t idle_pattern; // 1 byte + char custom_date_fmt[32]; // 32 bytes: custom strftime date format (date_format == 255) + uint8_t idle_start; // 1 byte + uint8_t idle_stop; // 1 byte + int8_t clock2_tz; // 1 byte + char clock2_desc[32]; // 32 bytes + uint8_t weather_format; // 1 byte + uint8_t weather_update; // 1 byte + char weather_lat[8]; // 8 bytes + char weather_lon[8]; // 8 bytes + uint8_t clock_font; // 1 byte + uint8_t token_type[2]; // 2 bytes + char token_code[2][65]; //130 bytes + uint8_t slots[10]; // 10 bytes +} __attribute__((__packed__)) persist_adv_settings; + +persist *settings_get(void); +persist_adv_settings *adv_settings_get(void); diff --git a/src/splash.c b/src/splash.c new file mode 100644 index 0000000..90655d9 --- /dev/null +++ b/src/splash.c @@ -0,0 +1,22 @@ +#include "splash.h" +#include "theme.h" +#include "layout.h" + +static Layer *s_splash_layer; + +static void splash_render(Layer *me, GContext* ctx) { + (void)me; + int w = layout_get().slot_bot.w; // full width: center the splash on any screen + setColors(ctx); + graphics_draw_text(ctx, "TimelyNG", fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD), GRect(0,0,w,36), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + graphics_draw_text(ctx, "3.0", fonts_get_system_font(FONT_KEY_GOTHIC_28), GRect(0,32,w,36), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); +} + +void splash_create(Layer *parent, GRect frame) { + s_splash_layer = layer_create(frame); + layer_set_update_proc(s_splash_layer, splash_render); + layer_add_child(parent, s_splash_layer); +} +void splash_destroy(void) { layer_destroy(s_splash_layer); } +void splash_set_hidden(bool hidden) { layer_set_hidden(s_splash_layer, hidden); } +void splash_set_frame(GRect frame) { layer_set_frame(s_splash_layer, frame); } diff --git a/src/splash.h b/src/splash.h new file mode 100644 index 0000000..82b7325 --- /dev/null +++ b/src/splash.h @@ -0,0 +1,7 @@ +#pragma once +#include +// Startup splash component (owns its layer). +void splash_create(Layer *parent, GRect frame); +void splash_destroy(void); +void splash_set_hidden(bool hidden); +void splash_set_frame(GRect frame); // resize/reposition on a live relayout diff --git a/src/suntimes.c b/src/suntimes.c new file mode 100644 index 0000000..74bb819 --- /dev/null +++ b/src/suntimes.c @@ -0,0 +1,27 @@ +#include "suntimes.h" +#include "math.h" // my_sin, my_cos, my_acos, my_tan + +#define SUN_PI 3.14159265358979 +#define DEG (SUN_PI / 180.0) + +void sun_times(float lat, float lon, int yday, float tz_hours, + float *sunrise, float *sunset) { + // Solar declination for the day (Cooper's approximation). + float decl = 23.45 * DEG * my_sin(2.0 * SUN_PI * (284 + yday) / 365.0); + float latr = lat * DEG; + float cosH = -my_tan(latr) * my_tan(decl); + if (cosH <= -1.0) { *sunrise = 0.0; *sunset = 24.0; return; } // polar day + if (cosH >= 1.0) { *sunrise = 12.0; *sunset = 12.0; return; } // polar night + float H = my_acos(cosH); // sunrise hour angle (radians) + float half = H * 12.0 / SUN_PI; // half the day length, hours + // Local solar noon in clock time (longitude + timezone correction; no EoT). + float noon = 12.0 - lon / 15.0 + tz_hours; + *sunrise = noon - half; + *sunset = noon + half; +} + +bool sun_is_night(float lat, float lon, int yday, float tz_hours, float now_hours) { + float sr, ss; + sun_times(lat, lon, yday, tz_hours, &sr, &ss); + return (now_hours < sr || now_hours >= ss); +} diff --git a/src/suntimes.h b/src/suntimes.h new file mode 100644 index 0000000..366de9e --- /dev/null +++ b/src/suntimes.h @@ -0,0 +1,12 @@ +#pragma once +#include +// Sunrise/sunset approximation (solar declination + hour angle; equation-of-time +// omitted, ~15min). Pure: uses math.c's trig, so it is host-testable. +// lat, lon : degrees (N/E positive). yday : 0-based day of year (tm_yday). +// tz_hours : offset from UTC in hours (e.g. +2 for CEST). +// Outputs sunrise/sunset as fractional LOCAL clock hours (0..24). +void sun_times(float lat, float lon, int yday, float tz_hours, + float *sunrise, float *sunset); + +// Is the given local time (fractional hours) outside [sunrise, sunset]? +bool sun_is_night(float lat, float lon, int yday, float tz_hours, float now_hours); diff --git a/src/theme.c b/src/theme.c new file mode 100644 index 0000000..d89eb86 --- /dev/null +++ b/src/theme.c @@ -0,0 +1,86 @@ +#include "theme.h" +#include "ui.h" // window, currentTime +#include "settings.h" // settings_get()->theme / theme_mode +#include "suntimes.h" +#include "timefmt.h" // TIMEZONE_UNINITIALIZED + +// Palettes: [theme][0=light, 1=dark]. Tune these via emulator screenshots. +static const Palette PALETTES[THEME_COUNT][2] = { + // THEME_MONO — pure black & white (the Pebble 2 Duo look, even on color) + { { GColorWhite, GColorBlack, GColorBlack, GColorWhite, GColorBlack, GColorBlack, GColorWhite }, + { GColorBlack, GColorWhite, GColorWhite, GColorBlack, GColorWhite, GColorWhite, GColorBlack } }, + // THEME_FUNCTIONAL — color carries meaning (today / weekend / warn) + { { GColorWhite, GColorBlack, GColorJaegerGreen, GColorWhite, GColorRed, GColorRed, GColorLightGray }, + { GColorBlack, GColorWhite, GColorJaegerGreen, GColorWhite, GColorMelon, GColorRed, GColorDarkGray } }, + // THEME_MINIMAL — monochrome + a single accent + { { GColorWhite, GColorBlack, GColorBlue, GColorWhite, GColorBlack, GColorBlack, GColorWhite }, + { GColorBlack, GColorWhite, GColorVividCerulean, GColorBlack, GColorWhite, GColorWhite, GColorBlack } }, + // THEME_VIBRANT — bold flat colors + { { GColorWhite, GColorBlack, GColorOrange, GColorWhite, GColorMagenta, GColorRed, GColorPastelYellow }, + { GColorOxfordBlue, GColorWhite, GColorChromeYellow, GColorBlack, GColorBrilliantRose, GColorRed, GColorDukeBlue } }, +}; + +#ifndef PBL_PLATFORM_APLITE +// Minimal "[-]int[.frac]" coordinate parser (Pebble libc lacks atof). +static bool parse_coord(const char *s, float *out) { + if (!s || !s[0]) return false; + int sign = 1; const char *p = s; + if (*p == '-') { sign = -1; p++; } else if (*p == '+') { p++; } + long ip = 0; float frac = 0.0f, scale = 0.1f; bool any = false; + while (*p >= '0' && *p <= '9') { ip = ip * 10 + (*p - '0'); p++; any = true; } + if (*p == '.') { p++; while (*p >= '0' && *p <= '9') { frac += (*p - '0') * scale; scale *= 0.1f; p++; any = true; } } + if (!any) return false; + *out = sign * (ip + frac); + return true; +} +#endif + +static bool is_night(void) { + if (!currentTime) return true; +#ifndef PBL_PLATFORM_APLITE + // Sunset/sunrise from the configured weather location. Skipped on aplite, + // whose 24KB app budget can't afford the float trig; it uses the window below. + float lat, lon; + if (timezone_offset != TIMEZONE_UNINITIALIZED && + parse_coord(adv_settings_get()->weather_lat, &lat) && + parse_coord(adv_settings_get()->weather_lon, &lon)) { + float now = currentTime->tm_hour + currentTime->tm_min / 60.0f; + float tz = -timezone_offset / 4.0f; // stored as quarter-hours, inverted sign + return sun_is_night(lat, lon, currentTime->tm_yday, tz, now); + } +#endif + // No location (or aplite): simple 19:00-06:59 night window. + return (currentTime->tm_hour >= 19 || currentTime->tm_hour < 7); +} + +Palette theme_palette(void) { + uint8_t theme = settings_get()->theme; + if (theme >= THEME_COUNT) theme = THEME_FUNCTIONAL; + uint8_t mode = settings_get()->theme_mode; + int dark = (mode == THEME_MODE_DARK) || (mode == THEME_MODE_AUTO && is_night()); + return PALETTES[theme][dark ? 1 : 0]; +} + +void setColors(GContext* ctx) { + Palette p = theme_palette(); + window_set_background_color(window, p.bg); + graphics_context_set_stroke_color(ctx, p.fg); + graphics_context_set_fill_color(ctx, p.bg); + graphics_context_set_text_color(ctx, p.fg); +} + +void setInvColors(GContext* ctx) { + Palette p = theme_palette(); + window_set_background_color(window, p.fg); + graphics_context_set_stroke_color(ctx, p.bg); + graphics_context_set_fill_color(ctx, p.fg); + graphics_context_set_text_color(ctx, p.bg); +} + +void setTodayColors(GContext* ctx) { + Palette p = theme_palette(); + window_set_background_color(window, p.bg); + graphics_context_set_stroke_color(ctx, p.accent_fg); + graphics_context_set_fill_color(ctx, p.accent); + graphics_context_set_text_color(ctx, p.accent_fg); +} diff --git a/src/theme.h b/src/theme.h new file mode 100644 index 0000000..9554c57 --- /dev/null +++ b/src/theme.h @@ -0,0 +1,24 @@ +#pragma once +#include +// Color roles for a single resolved palette. On 1-bit platforms colors degrade +// to black/white automatically. +typedef struct { + GColor bg; // background + GColor fg; // primary text / stroke + GColor accent; // today highlight / key accent + GColor accent_fg; // text drawn on the accent + GColor weekend; // weekend day color (functional theme) + GColor warn; // low battery / alert + GColor grid; // calendar grid background patch +} Palette; + +enum { THEME_MONO = 0, THEME_FUNCTIONAL = 1, THEME_MINIMAL = 2, THEME_VIBRANT = 3, THEME_COUNT }; +enum { THEME_MODE_LIGHT = 0, THEME_MODE_DARK = 1, THEME_MODE_AUTO = 2 }; + +// The active palette, resolved from settings (theme + mode; AUTO uses night). +Palette theme_palette(void); + +// Apply palette roles to a graphics context (and the window background): +void setColors(GContext* ctx); // base: bg + fg +void setInvColors(GContext* ctx); // swapped patch: fg-on-bg +void setTodayColors(GContext* ctx);// accent diff --git a/src/timefmt.c b/src/timefmt.c new file mode 100644 index 0000000..1531a2a --- /dev/null +++ b/src/timefmt.c @@ -0,0 +1,49 @@ +#include "timefmt.h" +#include +#include + +int daysInMonth(int mon, int year) { + mon++; // callers pass 0-based months (0 = January) + if (mon == 4 || mon == 6 || mon == 9 || mon == 11) { + return 30; + } else if (mon == 2) { + if (year % 400 == 0) return 29; + if (year % 100 == 0) return 28; + if (year % 4 == 0) return 29; + return 28; + } + return 31; +} + +int days_left_in_year(int year, int yday) { + int days_this_year = (daysInMonth(1, year) == 29) ? 366 : 365; + return days_this_year - yday - 1; +} + +void format_days_left_in_year(int days_left, char *buf, size_t n) { + if (days_left < 0) days_left = 0; + if (days_left > 365) days_left = 365; + snprintf(buf, n, "R%03d", days_left); +} + +void format_timezone_offset(int tz_offset, char *buf, size_t n) { + if (tz_offset == TIMEZONE_UNINITIALIZED) { + snprintf(buf, n, "UTC ?"); + return; + } + // Clamp to the real timezone domain (UTC-14..+14 in quarter-hours): guards + // against malformed config and bounds the formatted width. + if (tz_offset < -56) tz_offset = -56; + if (tz_offset > 56) tz_offset = 56; + int tz_mins = (tz_offset % 4) * 15; + int tz_hours = (tz_offset - (tz_offset % 4)) / 4; + // Sign convention is intentionally inverted to match the watchface's + // historical display: positive offset prints "UTC-", non-positive "UTC+". + if (tz_offset > 0) { + if (tz_mins == 0) snprintf(buf, n, "UTC-%d", tz_hours); + else snprintf(buf, n, "UTC-%d:%d", tz_hours, tz_mins); + } else { + if (tz_mins == 0) snprintf(buf, n, "UTC+%d", abs(tz_hours)); + else snprintf(buf, n, "UTC+%d:%d", abs(tz_hours), abs(tz_mins)); + } +} diff --git a/src/timefmt.h b/src/timefmt.h new file mode 100644 index 0000000..bf19834 --- /dev/null +++ b/src/timefmt.h @@ -0,0 +1,26 @@ +// Pure date/timezone formatting helpers, free of any Pebble SDK dependency so +// they can be unit-tested on the host. See tests/test_timefmt.c. +#pragma once + +#include + +// Sentinel for an offset that has not been received from the JS config yet. +#define TIMEZONE_UNINITIALIZED 80 + +// Number of days in a given month. mon is 0-based (0 = January, 11 = December); +// year is the full Gregorian year (e.g. 2026). Leap years handled per the +// Gregorian rules. +int daysInMonth(int mon, int year); + +// Days remaining in the year after the given day-of-year. year is the full +// Gregorian year; yday is 0-based (0 = Jan 1), matching struct tm's tm_yday. +// Returns 0..365. +int days_left_in_year(int year, int yday); + +// Format days-remaining as "R%03d" into buf (needs >= 5 bytes). days_left is +// clamped to [0, 365] before formatting. +void format_days_left_in_year(int days_left, char *buf, size_t n); + +// Format a quarter-hour timezone offset as "UTC", e.g. "UTC-5", "UTC+5:30", +// or "UTC ?" when uninitialized. buf needs >= 11 bytes (16 recommended). +void format_timezone_offset(int tz_offset, char *buf, size_t n); diff --git a/src/ui.h b/src/ui.h new file mode 100644 index 0000000..de5baa7 --- /dev/null +++ b/src/ui.h @@ -0,0 +1,9 @@ +#pragma once +#include +// Shared UI handles owned by the view (Timely.c) and used by split view modules. +extern Window *window; +extern GFont climacons; // weather icon font (loaded in window_load) +#include +extern struct tm *currentTime; // current time (updated each tick by the view) +extern GFont cal_normal, cal_bold; // calendar day-label fonts (loaded in window_load) +extern int8_t timezone_offset; // quarter-hour TZ offset (inverted sign; see update_timezone_text) diff --git a/src/vibes.c b/src/vibes.c new file mode 100644 index 0000000..5bcc219 --- /dev/null +++ b/src/vibes.c @@ -0,0 +1,9 @@ +#include "vibes.h" + +bool period_contains(uint8_t start_incr, uint8_t stop_incr, + uint8_t current_incr, bool retval_on_equal) { + if (start_incr == stop_incr) return retval_on_equal; + if (start_incr > stop_incr) // window wraps past midnight + return (current_incr >= start_incr || current_incr <= stop_incr); + return (current_incr >= start_incr && current_incr <= stop_incr); +} diff --git a/src/vibes.h b/src/vibes.h new file mode 100644 index 0000000..fbdb02c --- /dev/null +++ b/src/vibes.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +// Is current_incr within the [start_incr, stop_incr] window? Units are 10-minute +// increments (0-143 = hour*6 + min/10). Inclusive; may wrap past midnight +// (start > stop). Equal start/stop returns retval_on_equal. +bool period_contains(uint8_t start_incr, uint8_t stop_incr, + uint8_t current_incr, bool retval_on_equal); diff --git a/src/weather.c b/src/weather.c new file mode 100644 index 0000000..2fa3e02 --- /dev/null +++ b/src/weather.c @@ -0,0 +1,58 @@ +#include "weather.h" +#include "theme.h" +#include "debug.h" +#include "ui.h" + +// Single owner of the weather state (default: 999 = "N/A", 'h' = updating glyph). +static weather_data s_weather = { + .current = 999, + .condition = {'h'}, + .requests = 0, + .failures = 0, + .city = "", +}; + +weather_data *weather_state(void) { return &s_weather; } + +static Layer *s_weather_layer; +static bool s_compact = false; // narrow screens (144px): smaller glyph + temperature + +void weather_set_compact(bool compact) { s_compact = compact; } + +static void weather_render(Layer *me, GContext *ctx) { + static char temp_current[12] = "N/A"; + static char cond_current[4] = "0"; + if (weather_state()->current < 900) { + snprintf(temp_current, sizeof(temp_current), "%d°", weather_state()->current); + } else { + snprintf(temp_current, sizeof(temp_current), "N/A"); + } + cond_current[0] = weather_state()->condition[0]; // single climacons glyph + cond_current[1] = '\0'; + + setColors(ctx); + // The layer IS the time band. The icon-over-temperature block is centred in + // the band so it lines up with the clock and fills the column (big glyph on + // wide screens), without spilling into the calendar below. + GFont temp_font = fonts_get_system_font(s_compact ? FONT_KEY_GOTHIC_18 : FONT_KEY_GOTHIC_28); + int icon_w = s_compact ? 34 : 42; // climacons box (28px glyph / 40px glyph) + int gap = s_compact ? 18 : 32; // temperature baseline below the icon top + int temp_h = s_compact ? 18 : 28; + int band_h = layer_get_bounds(me).size.h; + int block = gap + temp_h; // total visual height of icon+temp + int top = (band_h - block) / 2; + if (top < 0) { top = 0; } + graphics_draw_text(ctx, cond_current, climacons, GRect(2, top, icon_w, gap + 8), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + graphics_draw_text(ctx, temp_current, temp_font, GRect(2, top + gap, icon_w + 2, temp_h + 8), GTextOverflowModeWordWrap, GTextAlignmentCenter, NULL); + if (debug_get()->general) { app_log(APP_LOG_LEVEL_DEBUG, __FILE__, __LINE__, "Weather redrawing: %d, %s", weather_state()->current, weather_state()->condition); } +} + +void weather_create(Layer *parent, GRect frame) { + s_weather_layer = layer_create(frame); + layer_set_update_proc(s_weather_layer, weather_render); + layer_add_child(parent, s_weather_layer); +} +void weather_destroy(void) { layer_destroy(s_weather_layer); } +void weather_set_frame(GRect frame) { layer_set_frame(s_weather_layer, frame); } +void weather_set_hidden(bool hidden) { layer_set_hidden(s_weather_layer, hidden); } +void weather_mark_dirty(void) { layer_mark_dirty(s_weather_layer); } diff --git a/src/weather.h b/src/weather.h new file mode 100644 index 0000000..8154090 --- /dev/null +++ b/src/weather.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include +// Weather state, owned by weather.c. Access only through weather_state(). +typedef struct weather_data { + int16_t current; // current temperature + char condition[2]; // weather condition (mapped to a single climacons glyph) + int16_t requests; // requests sent since last success + int16_t failures; // failed responses since last success + char city[24]; // location name from the weather provider (display only) +} __attribute__((__packed__)) weather_data; + +weather_data *weather_state(void); + +// Weather display component — owns its layer. +void weather_create(Layer *parent, GRect frame); +void weather_destroy(void); +void weather_set_frame(GRect frame); +void weather_set_hidden(bool hidden); +void weather_set_compact(bool compact); // narrow screens: smaller glyph + temperature +void weather_mark_dirty(void); diff --git a/tests/test_calendar.c b/tests/test_calendar.c new file mode 100644 index 0000000..264e47e --- /dev/null +++ b/tests/test_calendar.c @@ -0,0 +1,29 @@ +#include "utest.h" +#include "calendar.h" + +// Thu June 4 2026, Sunday start, 1 week before + 1 after. +// Matches the emulator screenshot: 24..30 / 31,1..6 / 7..13, today=4. +UTEST(calendar, june_2026_sunday_start) { + CalGrid g = calendar_build(2026, 5, 4, 4, 0, 0); + ASSERT_EQ(4, g.special_col); + ASSERT_EQ(2, g.special_row); + ASSERT_EQ(24, g.days[0]); + ASSERT_EQ(30, g.days[6]); + ASSERT_EQ(31, g.days[7]); + ASSERT_EQ(4, g.days[11]); + ASSERT_EQ(6, g.days[13]); + ASSERT_EQ(7, g.days[14]); + ASSERT_EQ(13, g.days[20]); +} + +UTEST(calendar, monday_start_offset) { + CalGrid g = calendar_build(2026, 5, 4, 4, 1, 0); + ASSERT_EQ(3, g.special_col); + ASSERT_EQ(1, g.days[7]); // Monday Jun 1 starts the week row + ASSERT_EQ(4, g.days[10]); // today +} + +UTEST(calendar, sunday_with_monday_start_wraps_to_last_col) { + CalGrid g = calendar_build(2026, 5, 7, 0, 1, 0); + ASSERT_EQ(6, g.special_col); // Sunday is the last column under Monday start +} diff --git a/tests/test_layout.c b/tests/test_layout.c new file mode 100644 index 0000000..64dbc3a --- /dev/null +++ b/tests/test_layout.c @@ -0,0 +1,64 @@ +#include "utest.h" +#include "layout.h" + +// Default layout_compute() = all three rows present (TOP + CENTER + BOTTOM). +UTEST(layout, all_rows_144x168) { + TimelyLayout L = layout_compute(144, 168); + ASSERT_EQ(0, L.statusbar.y); ASSERT_EQ(24, L.statusbar.h); + ASSERT_EQ(144, L.statusbar.w); + ASSERT_EQ(24, L.slot_top.y); ASSERT_EQ(88, L.slot_top.h); // center 26 + time 40 + bottom 22 + ASSERT_EQ(112, L.slot_bot.y); ASSERT_EQ(56, L.slot_bot.h); // 168-24-88 + ASSERT_EQ(7, L.cal_cols); + ASSERT_EQ(20, L.cal_cell_w); // (144-4)/7 + ASSERT_EQ(14, L.cal_cell_h); // 56/4 +} + +UTEST(layout, all_rows_emery_200x228) { + TimelyLayout L = layout_compute(200, 228); + ASSERT_EQ(24, L.statusbar.h); + ASSERT_EQ(200, L.statusbar.w); + ASSERT_EQ(24, L.slot_top.y); + ASSERT_EQ(113, L.slot_top.h); // center 26 + time 65 + bottom 22 + ASSERT_EQ(137, L.slot_bot.y); // 24+113 + ASSERT_EQ(91, L.slot_bot.h); // 228-24-113 + ASSERT_EQ(28, L.cal_cell_w); // (200-4)/7 + ASSERT_EQ(152, L.battery.x); // 200-48 +} + +// Disabling rows frees their height: the calendar (and clock) grow. +UTEST(layout, disabling_rows_grows_calendar_144) { + TimelyLayout full = layout_compute_rows(144, 168, 1, 1, 1); // all rows + TimelyLayout none = layout_compute_rows(144, 168, 1, 0, 0); // top only + ASSERT_TRUE(none.slot_bot.h > full.slot_bot.h); // calendar grew + ASSERT_TRUE(none.clock_time.h >= full.clock_time.h); // clock grew (or equal) + ASSERT_EQ(0, none.clock_date.h); // center band collapsed +} + +// No status bar (TOP off) reclaims its band too. +UTEST(layout, no_statusbar_reclaims_band) { + TimelyLayout L = layout_compute_rows(144, 168, 0, 1, 1); + ASSERT_EQ(0, L.statusbar.h); + ASSERT_EQ(0, L.slot_top.y); // slot_top starts at the very top now +} + +// CENTER/BOTTOM bands collapse to zero exactly when their row is off, and the +// three slot_top bands sum to slot_top.h. +UTEST(layout, bands_collapse_and_sum) { + TimelyLayout a = layout_compute_rows(144, 168, 1, 1, 0); // no bottom + ASSERT_EQ(a.slot_top.h, a.subtext_top); // zero-height bottom sits at the end + TimelyLayout b = layout_compute_rows(144, 168, 1, 0, 1); // no center + ASSERT_EQ(0, b.clock_date.h); // center band gone + ASSERT_EQ(0, b.clock_time.y); // time band starts at top of slot_top + // time band + bottom band fit inside slot_top + ASSERT_TRUE(b.clock_time.y + b.clock_time.h <= b.slot_top.h); +} + +// Pure clock-font selection: scales with band height; Roboto only on wide+tall. +UTEST(clockfont, scales_with_band_and_width) { + ASSERT_EQ(CLOCK_FONT_ROBOTO_49, clock_font_for(200, 65)); // emery, tall band + ASSERT_EQ(CLOCK_FONT_LECO_42, clock_font_for(200, 55)); // wide but shorter -> LECO + ASSERT_EQ(CLOCK_FONT_LECO_42, clock_font_for(144, 70)); // narrow never gets Roboto + ASSERT_EQ(CLOCK_FONT_LECO_38, clock_font_for(144, 49)); // 144 default band + ASSERT_EQ(CLOCK_FONT_LECO_32, clock_font_for(144, 40)); + ASSERT_EQ(CLOCK_FONT_LECO_28, clock_font_for(144, 30)); // very short +} diff --git a/tests/test_main.c b/tests/test_main.c new file mode 100644 index 0000000..97f9590 --- /dev/null +++ b/tests/test_main.c @@ -0,0 +1,4 @@ +// Single entry point for the host unit-test suite. Individual tests live in +// tests/test_*.c (utest.h registers them via constructors across files). +#include "utest.h" +UTEST_MAIN(); diff --git a/tests/test_suntimes.c b/tests/test_suntimes.c new file mode 100644 index 0000000..48e7e92 --- /dev/null +++ b/tests/test_suntimes.c @@ -0,0 +1,14 @@ +#include "utest.h" +#include "suntimes.h" + +// Milan (45.46N, 9.19E), 4 June (yday 154), CEST = UTC+2. +// Real sunrise ~05:33, sunset ~21:01; we allow ~30min (EoT omitted). +UTEST(suntimes, milan_june) { + float sr, ss; + sun_times(45.46f, 9.19f, 154, 2.0f, &sr, &ss); + ASSERT_TRUE(sr > 5.0f && sr < 6.5f); + ASSERT_TRUE(ss > 20.0f && ss < 21.5f); + ASSERT_FALSE(sun_is_night(45.46f, 9.19f, 154, 2.0f, 12.0f)); // noon: day + ASSERT_TRUE(sun_is_night(45.46f, 9.19f, 154, 2.0f, 2.0f)); // 02:00: night + ASSERT_TRUE(sun_is_night(45.46f, 9.19f, 154, 2.0f, 23.0f)); // 23:00: night +} diff --git a/tests/test_timefmt.c b/tests/test_timefmt.c new file mode 100644 index 0000000..e6ef54c --- /dev/null +++ b/tests/test_timefmt.c @@ -0,0 +1,46 @@ +// Host-side unit tests for src/timefmt.c (pure logic, no Pebble SDK). +// Build/run via `make test`, `just test`, or `nix flake check`. +#include "utest.h" +#include "timefmt.h" + +UTEST(daysInMonth, february_leap_rules) { + ASSERT_EQ(29, daysInMonth(1, 2000)); // divisible by 400 -> leap + ASSERT_EQ(28, daysInMonth(1, 1900)); // divisible by 100, not 400 -> common + ASSERT_EQ(29, daysInMonth(1, 2024)); // divisible by 4 -> leap + ASSERT_EQ(28, daysInMonth(1, 2026)); // common year +} + +UTEST(daysInMonth, month_lengths) { + ASSERT_EQ(31, daysInMonth(0, 2026)); // January + ASSERT_EQ(30, daysInMonth(3, 2026)); // April + ASSERT_EQ(31, daysInMonth(11, 2026)); // December +} + +UTEST(days_left_in_year, boundaries) { + ASSERT_EQ(364, days_left_in_year(2026, 0)); // common year, Jan 1 + ASSERT_EQ(0, days_left_in_year(2026, 364)); // common year, Dec 31 + ASSERT_EQ(365, days_left_in_year(2024, 0)); // leap year, Jan 1 + ASSERT_EQ(0, days_left_in_year(2024, 365)); // leap year, Dec 31 +} + +UTEST(format_days_left_in_year, typical_and_clamp) { + char b[5]; + format_days_left_in_year(364, b, sizeof(b)); ASSERT_STREQ("R364", b); + format_days_left_in_year(0, b, sizeof(b)); ASSERT_STREQ("R000", b); + format_days_left_in_year(5, b, sizeof(b)); ASSERT_STREQ("R005", b); + format_days_left_in_year(-3, b, sizeof(b)); ASSERT_STREQ("R000", b); // clamp low + format_days_left_in_year(9999, b, sizeof(b)); ASSERT_STREQ("R365", b); // clamp high +} + +// Preserves the watchface's existing (inverted-sign) convention: +// positive offset -> "UTC-", non-positive -> "UTC+". +UTEST(format_timezone_offset, formats) { + char b[16]; + format_timezone_offset(TIMEZONE_UNINITIALIZED, b, sizeof(b)); ASSERT_STREQ("UTC ?", b); + format_timezone_offset(0, b, sizeof(b)); ASSERT_STREQ("UTC+0", b); + format_timezone_offset(4, b, sizeof(b)); ASSERT_STREQ("UTC-1", b); // +1h east + format_timezone_offset(5, b, sizeof(b)); ASSERT_STREQ("UTC-1:15", b); // +1h15 + format_timezone_offset(-4, b, sizeof(b)); ASSERT_STREQ("UTC+1", b); // -1h west + format_timezone_offset(-5, b, sizeof(b)); ASSERT_STREQ("UTC+1:15", b); // -1h15 +} + diff --git a/tests/test_vibes.c b/tests/test_vibes.c new file mode 100644 index 0000000..e021b91 --- /dev/null +++ b/tests/test_vibes.c @@ -0,0 +1,18 @@ +#include "utest.h" +#include "vibes.h" + +UTEST(vibes, single_day_window) { + ASSERT_TRUE(period_contains(60, 120, 90, false)); // 10:00-20:00, now 15:00 + ASSERT_FALSE(period_contains(60, 120, 30, false)); // now 05:00, outside + ASSERT_TRUE(period_contains(60, 120, 60, false)); // inclusive start + ASSERT_TRUE(period_contains(60, 120, 120, false)); // inclusive stop +} +UTEST(vibes, wraps_midnight) { + ASSERT_TRUE(period_contains(132, 36, 138, false)); // 22:00-06:00, now 23:00 + ASSERT_TRUE(period_contains(132, 36, 12, false)); // now 02:00 + ASSERT_FALSE(period_contains(132, 36, 72, false)); // now 12:00, outside +} +UTEST(vibes, equal_start_stop_returns_flag) { + ASSERT_FALSE(period_contains(60, 60, 90, false)); + ASSERT_TRUE(period_contains(60, 60, 90, true)); +} diff --git a/tests/utest.h b/tests/utest.h new file mode 100644 index 0000000..3035ffe --- /dev/null +++ b/tests/utest.h @@ -0,0 +1,1767 @@ +/* + The latest version of this library is available on GitHub; + https://github.com/sheredom/utest.h +*/ + +/* + This is free and unencumbered software released into the public domain. + + Anyone is free to copy, modify, publish, use, compile, sell, or + distribute this software, either in source code form or as a compiled + binary, for any purpose, commercial or non-commercial, and by any + means. + + In jurisdictions that recognize copyright laws, the author or authors + of this software dedicate any and all copyright interest in the + software to the public domain. We make this dedication for the benefit + of the public at large and to the detriment of our heirs and + successors. We intend this dedication to be an overt act of + relinquishment in perpetuity of all present and future rights to this + software under copyright law. + + 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 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. + + For more information, please refer to +*/ + +#ifndef SHEREDOM_UTEST_H_INCLUDED +#define SHEREDOM_UTEST_H_INCLUDED + +#ifdef _MSC_VER +/* + Disable warning about not inlining 'inline' functions. +*/ +#pragma warning(disable : 4710) + +/* + Disable warning about inlining functions that are not marked 'inline'. +*/ +#pragma warning(disable : 4711) + +/* + Disable warning for alignment padding added +*/ +#pragma warning(disable : 4820) + +#if _MSC_VER > 1900 +/* + Disable warning about preprocessor macros not being defined in MSVC headers. +*/ +#pragma warning(disable : 4668) + +/* + Disable warning about no function prototype given in MSVC headers. +*/ +#pragma warning(disable : 4255) + +/* + Disable warning about pointer or reference to potentially throwing function. +*/ +#pragma warning(disable : 5039) + +/* + Disable warning about macro expansion producing 'defined' has undefined + behavior. +*/ +#pragma warning(disable : 5105) +#endif + +#if _MSC_VER > 1930 +/* + Disable warning about 'const' variable is not used. +*/ +#pragma warning(disable : 5264) +#endif + +#pragma warning(push, 1) +#endif + +#if defined(_MSC_VER) && (_MSC_VER < 1920) +typedef __int64 utest_int64_t; +typedef unsigned __int64 utest_uint64_t; +typedef unsigned __int32 utest_uint32_t; +#else +#include +typedef int64_t utest_int64_t; +typedef uint64_t utest_uint64_t; +typedef uint32_t utest_uint32_t; +#endif + +#include +#include +#include +#include +#include + +#if defined(__cplusplus) +#if defined(_MSC_VER) && !defined(_CPPUNWIND) +/* We're on MSVC and the compiler is compiling without exception support! */ +#elif !defined(_MSC_VER) && !defined(__EXCEPTIONS) +/* We're on a GCC/Clang compiler that doesn't have exception support! */ +#else +#define UTEST_HAS_EXCEPTIONS 1 +#endif +#endif + +#if defined(UTEST_HAS_EXCEPTIONS) +#include +#endif + +#if defined(_MSC_VER) +#pragma warning(pop) +#endif + +#if defined(__cplusplus) +#define UTEST_C_FUNC extern "C" +#else +#define UTEST_C_FUNC +#endif + +#define UTEST_TEST_PASSED (0) +#define UTEST_TEST_FAILURE (1) +#define UTEST_TEST_SKIPPED (2) + +#if defined(__TINYC__) +#define UTEST_ATTRIBUTE(a) __attribute((a)) +#else +#define UTEST_ATTRIBUTE(a) __attribute__((a)) +#endif + +#if defined(_MSC_VER) || defined(__MINGW64__) || defined(__MINGW32__) + +#if defined(__MINGW64__) || defined(__MINGW32__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpragmas" +#pragma GCC diagnostic ignored "-Wunknown-pragmas" +#endif + +#if defined(_WINDOWS_) || defined(_WINDOWS_H) +typedef LARGE_INTEGER utest_large_integer; +#else +// use old QueryPerformanceCounter definitions (not sure is this needed in some +// edge cases or not) on Win7 with VS2015 these extern declaration cause "second +// C linkage of overloaded function not allowed" error +typedef union { + struct { + unsigned long LowPart; + long HighPart; + } DUMMYSTRUCTNAME; + struct { + unsigned long LowPart; + long HighPart; + } u; + utest_int64_t QuadPart; +} utest_large_integer; + +UTEST_C_FUNC __declspec(dllimport) int __stdcall QueryPerformanceCounter( + utest_large_integer *); +UTEST_C_FUNC __declspec(dllimport) int __stdcall QueryPerformanceFrequency( + utest_large_integer *); + +#if defined(__MINGW64__) || defined(__MINGW32__) +#pragma GCC diagnostic pop +#endif +#endif + +#elif defined(__linux__) || defined(__FreeBSD__) || defined(__OpenBSD__) || \ + defined(__NetBSD__) || defined(__DragonFly__) || defined(__sun__) || \ + defined(__HAIKU__) +/* + slightly obscure include here - we need to include glibc's features.h, but + we don't want to just include a header that might not be defined for other + c libraries like musl. Instead we include limits.h, which we know on all + glibc distributions includes features.h +*/ +#include + +#if defined(__GLIBC__) && defined(__GLIBC_MINOR__) +#include + +#if ((2 < __GLIBC__) || ((2 == __GLIBC__) && (17 <= __GLIBC_MINOR__))) +/* glibc is version 2.17 or above, so we can just use clock_gettime */ +#define UTEST_USE_CLOCKGETTIME +#else +#include +#include +#endif +#else // Other libc implementations +#include +#define UTEST_USE_CLOCKGETTIME +#endif + +#elif defined(__APPLE__) +#include +#endif + +#if defined(_MSC_VER) && (_MSC_VER < 1920) +#define UTEST_PRId64 "I64d" +#define UTEST_PRIu64 "I64u" +#else +#include + +#define UTEST_PRId64 PRId64 +#define UTEST_PRIu64 PRIu64 +#endif + +#if defined(__cplusplus) +#define UTEST_INLINE inline + +#if defined(__clang__) +#define UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wglobal-constructors\"") + +#define UTEST_INITIALIZER_END_DISABLE_WARNINGS _Pragma("clang diagnostic pop") +#else +#define UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS +#define UTEST_INITIALIZER_END_DISABLE_WARNINGS +#endif + +#define UTEST_INITIALIZER(f) \ + struct f##_cpp_struct { \ + f##_cpp_struct(); \ + }; \ + UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS static f##_cpp_struct \ + f##_cpp_global UTEST_INITIALIZER_END_DISABLE_WARNINGS; \ + f##_cpp_struct::f##_cpp_struct() +#elif defined(_MSC_VER) +#define UTEST_INLINE __forceinline + +#if defined(_WIN64) +#define UTEST_SYMBOL_PREFIX +#else +#define UTEST_SYMBOL_PREFIX "_" +#endif + +#if defined(__clang__) +#define UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wmissing-variable-declarations\"") + +#define UTEST_INITIALIZER_END_DISABLE_WARNINGS _Pragma("clang diagnostic pop") +#else +#define UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS +#define UTEST_INITIALIZER_END_DISABLE_WARNINGS +#endif + +#pragma section(".CRT$XCU", read) +#define UTEST_INITIALIZER(f) \ + static void __cdecl f(void); \ + UTEST_INITIALIZER_BEGIN_DISABLE_WARNINGS \ + __pragma(comment(linker, "/include:" UTEST_SYMBOL_PREFIX #f "_")) \ + UTEST_C_FUNC \ + __declspec(allocate(".CRT$XCU")) void(__cdecl * f##_)(void) = f; \ + UTEST_INITIALIZER_END_DISABLE_WARNINGS \ + static void __cdecl f(void) +#else +#if defined(__linux__) +#if defined(__clang__) +#if __has_warning("-Wreserved-id-macro") +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wreserved-id-macro" +#endif +#endif + +#define __STDC_FORMAT_MACROS 1 + +#if defined(__clang__) +#if __has_warning("-Wreserved-id-macro") +#pragma clang diagnostic pop +#endif +#endif +#endif + +#define UTEST_INLINE inline + +#define UTEST_INITIALIZER(f) \ + static void f(void) UTEST_ATTRIBUTE(constructor); \ + static void f(void) +#endif + +#if defined(__cplusplus) +#define UTEST_CAST(type, x) static_cast(x) +#define UTEST_PTR_CAST(type, x) reinterpret_cast(x) +#define UTEST_EXTERN extern "C" +#define UTEST_NULL NULL +#else +#define UTEST_CAST(type, x) ((type)(x)) +#define UTEST_PTR_CAST(type, x) ((type)(x)) +#define UTEST_EXTERN extern +#define UTEST_NULL 0 +#endif + +#ifdef _MSC_VER +/* + io.h contains definitions for some structures with natural padding. This is + uninteresting, but for some reason MSVC's behaviour is to warn about + including this system header. That *is* interesting +*/ +#pragma warning(disable : 4820) +#pragma warning(push, 1) +#include +#pragma warning(pop) +#define UTEST_COLOUR_OUTPUT() (_isatty(_fileno(stdout))) +#else +#if defined(__EMSCRIPTEN__) +#include +#define UTEST_COLOUR_OUTPUT() false +#else +#include +#define UTEST_COLOUR_OUTPUT() (isatty(STDOUT_FILENO)) +#endif +#endif + +static UTEST_INLINE void *utest_realloc(void *const pointer, size_t new_size) { + void *const new_pointer = realloc(pointer, new_size); + + if (UTEST_NULL == new_pointer) { + free(pointer); + } + + return new_pointer; +} + +// Prevent 64-bit integer overflow when computing a timestamp by using a trick +// from Sokol: +// https://github.com/floooh/sokol/blob/189843bf4f86969ca4cc4b6d94e793a37c5128a7/sokol_time.h#L204 +static UTEST_INLINE utest_int64_t utest_mul_div(const utest_int64_t value, + const utest_int64_t numer, + const utest_int64_t denom) { + const utest_int64_t q = value / denom; + const utest_int64_t r = value % denom; + return q * numer + r * numer / denom; +} + +static UTEST_INLINE utest_int64_t utest_ns(void) { +#if defined(_MSC_VER) || defined(__MINGW64__) || defined(__MINGW32__) + utest_large_integer counter; + utest_large_integer frequency; + QueryPerformanceCounter(&counter); + QueryPerformanceFrequency(&frequency); + return utest_mul_div(counter.QuadPart, 1000000000, frequency.QuadPart); +#elif defined(__linux__) && defined(__STRICT_ANSI__) + return utest_mul_div(clock(), 1000000000, CLOCKS_PER_SEC); +#elif defined(__APPLE__) + return UTEST_CAST(utest_int64_t, clock_gettime_nsec_np(CLOCK_UPTIME_RAW)); +#elif defined(__linux__) || defined(__FreeBSD__) || defined(__OpenBSD__) || \ + defined(__NetBSD__) || defined(__DragonFly__) || defined(__sun__) || \ + defined(__HAIKU__) + struct timespec ts = {0, 0}; +#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L) && \ + !defined(__HAIKU__) + timespec_get(&ts, TIME_UTC); +#else + const clockid_t cid = CLOCK_REALTIME; +#if defined(UTEST_USE_CLOCKGETTIME) + clock_gettime(cid, &ts); +#else + syscall(SYS_clock_gettime, cid, &ts); +#endif +#endif + return UTEST_CAST(utest_int64_t, ts.tv_sec) * 1000 * 1000 * 1000 + ts.tv_nsec; +#elif __EMSCRIPTEN__ + return emscripten_performance_now() * 1000000.0; +#else +#error Unsupported platform! +#endif +} + +typedef void (*utest_testcase_t)(int *, size_t); + +struct utest_test_state_s { + utest_testcase_t func; + size_t index; + char *name; +}; + +struct utest_state_s { + struct utest_test_state_s *tests; + size_t tests_length; + FILE *output; +}; + +/* extern to the global state utest needs to execute */ +UTEST_EXTERN struct utest_state_s utest_state; + +#if defined(_MSC_VER) +#define UTEST_WEAK __forceinline +#elif defined(__MINGW32__) || defined(__MINGW64__) +#define UTEST_WEAK static UTEST_ATTRIBUTE(used) +#elif defined(__clang__) || defined(__GNUC__) || defined(__TINYC__) +#define UTEST_WEAK UTEST_ATTRIBUTE(weak) +#else +#error Non clang, non gcc, non MSVC, non tcc compiler found! +#endif + +#if defined(_MSC_VER) +#define UTEST_UNUSED +#else +#define UTEST_UNUSED UTEST_ATTRIBUTE(unused) +#endif + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wvariadic-macros" +#pragma clang diagnostic ignored "-Wc++98-compat-pedantic" +#endif +#define UTEST_PRINTF(...) \ + if (utest_state.output) { \ + fprintf(utest_state.output, __VA_ARGS__); \ + } \ + printf(__VA_ARGS__) +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wvariadic-macros" +#pragma clang diagnostic ignored "-Wc++98-compat-pedantic" +#endif + +#ifdef _MSC_VER +#define UTEST_SNPRINTF(BUFFER, N, ...) _snprintf_s(BUFFER, N, N, __VA_ARGS__) +#else +#define UTEST_SNPRINTF(...) snprintf(__VA_ARGS__) +#endif + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +#if defined(__cplusplus) +/* if we are using c++ we can use overloaded methods (its in the language) */ +#define UTEST_OVERLOADABLE +#elif defined(__clang__) +/* otherwise, if we are using clang with c - use the overloadable attribute */ +#define UTEST_OVERLOADABLE UTEST_ATTRIBUTE(overloadable) +#endif + +#if defined(__cplusplus) && (__cplusplus >= 201103L) + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc++98-compat-pedantic" +#endif + +#include + +template ::value> +struct utest_type_deducer final { + static void _(const T t); +}; + +template <> struct utest_type_deducer { + static void _(const char c) { + if (std::is_signed::value) { + UTEST_PRINTF("%d", static_cast(c)); + } else { + UTEST_PRINTF("%u", static_cast(c)); + } + } +}; +template <> struct utest_type_deducer { + static void _(const signed char c) { + UTEST_PRINTF("%d", static_cast(c)); + } +}; + +template <> struct utest_type_deducer { + static void _(const unsigned char c) { + UTEST_PRINTF("%u", static_cast(c)); + } +}; + +template <> struct utest_type_deducer { + static void _(const short s) { UTEST_PRINTF("%d", static_cast(s)); } +}; + +template <> struct utest_type_deducer { + static void _(const unsigned short s) { + UTEST_PRINTF("%u", static_cast(s)); + } +}; + +template <> struct utest_type_deducer { + static void _(const float f) { UTEST_PRINTF("%f", static_cast(f)); } +}; + +template <> struct utest_type_deducer { + static void _(const double d) { UTEST_PRINTF("%f", d); } +}; + +template <> struct utest_type_deducer { + static void _(const long double d) { +#if defined(__MINGW32__) || defined(__MINGW64__) + /* MINGW is weird - doesn't like LF at all?! */ + UTEST_PRINTF("%f", (double)d); +#else + UTEST_PRINTF("%Lf", d); +#endif + } +}; + +template <> struct utest_type_deducer { + static void _(const int i) { UTEST_PRINTF("%d", i); } +}; + +template <> struct utest_type_deducer { + static void _(const unsigned int i) { UTEST_PRINTF("%u", i); } +}; + +template <> struct utest_type_deducer { + static void _(const long i) { UTEST_PRINTF("%ld", i); } +}; + +template <> struct utest_type_deducer { + static void _(const unsigned long i) { UTEST_PRINTF("%lu", i); } +}; + +template <> struct utest_type_deducer { + static void _(const long long i) { UTEST_PRINTF("%lld", i); } +}; + +template <> struct utest_type_deducer { + static void _(const unsigned long long i) { UTEST_PRINTF("%llu", i); } +}; + +template <> struct utest_type_deducer { + static void _(const bool i) { UTEST_PRINTF(i ? "true" : "false"); } +}; + +template struct utest_type_deducer { + static void _(const T *t) { + UTEST_PRINTF("%p", static_cast(t)); + } +}; + +template struct utest_type_deducer { + static void _(T *t) { UTEST_PRINTF("%p", static_cast(t)); } +}; + +template struct utest_type_deducer { + static void _(const T t) { + UTEST_PRINTF("%llu", static_cast(t)); + } +}; + +// default printer for all other objects (specialize for custom printing) +template struct utest_type_deducer { + static void _(const T &t) { + UTEST_PRINTF("(object %p)", static_cast(&t)); + } +}; + +template <> struct utest_type_deducer { + static void _(std::nullptr_t t) { + UTEST_PRINTF("%p", static_cast(t)); + } +}; + +template +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(const T &t) { + utest_type_deducer::_(t); +} + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +#elif defined(UTEST_OVERLOADABLE) + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(signed char c); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(signed char c) { + UTEST_PRINTF("%d", UTEST_CAST(int, c)); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(unsigned char c); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(unsigned char c) { + UTEST_PRINTF("%u", UTEST_CAST(unsigned int, c)); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(float f); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(float f) { + UTEST_PRINTF("%f", UTEST_CAST(double, f)); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(double d); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(double d) { + UTEST_PRINTF("%f", d); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long double d); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long double d) { +#if defined(__MINGW32__) || defined(__MINGW64__) + /* MINGW is weird - doesn't like LF at all?! */ + UTEST_PRINTF("%f", (double)d); +#else + UTEST_PRINTF("%Lf", d); +#endif +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(int i); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(int i) { + UTEST_PRINTF("%d", i); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(unsigned int i); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(unsigned int i) { + UTEST_PRINTF("%u", i); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long int i); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long int i) { + UTEST_PRINTF("%ld", i); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long unsigned int i); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long unsigned int i) { + UTEST_PRINTF("%lu", i); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(const void *p); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(const void *p) { + UTEST_PRINTF("%p", p); +} + +/* + long long is a c++11 extension +*/ +#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L) || \ + defined(__cplusplus) && (__cplusplus >= 201103L) || \ + (defined(__MINGW32__) || defined(__MINGW64__)) + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc++98-compat-pedantic" +#endif + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long long int i); +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long long int i) { + UTEST_PRINTF("%lld", i); +} + +UTEST_WEAK UTEST_OVERLOADABLE void utest_type_printer(long long unsigned int i); +UTEST_WEAK UTEST_OVERLOADABLE void +utest_type_printer(long long unsigned int i) { + UTEST_PRINTF("%llu", i); +} + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +#endif +#elif defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L) && \ + !(defined(__MINGW32__) || defined(__MINGW64__)) || \ + defined(__TINYC__) +#define utest_type_printer(val) \ + UTEST_PRINTF( \ + _Generic((val), \ + signed char: "%d", \ + unsigned char: "%u", \ + short: "%d", \ + unsigned short: "%u", \ + int: "%d", \ + long: "%ld", \ + long long: "%lld", \ + unsigned: "%u", \ + unsigned long: "%lu", \ + unsigned long long: "%llu", \ + float: "%f", \ + double: "%f", \ + long double: "%Lf", \ + default: _Generic((val - val), ptrdiff_t: "%p", default: "undef")), \ + (val)) +#else +/* + we don't have the ability to print the values we got, so we create a macro + to tell our users we can't do anything fancy +*/ +#define utest_type_printer(...) UTEST_PRINTF("undef") +#endif + +#if defined(_MSC_VER) +#define UTEST_SURPRESS_WARNING_BEGIN \ + __pragma(warning(push)) __pragma(warning(disable : 4127)) \ + __pragma(warning(disable : 4571)) __pragma(warning(disable : 4130)) +#define UTEST_SURPRESS_WARNING_END __pragma(warning(pop)) +#else +#define UTEST_SURPRESS_WARNING_BEGIN +#define UTEST_SURPRESS_WARNING_END +#endif + +#if defined(__cplusplus) && (__cplusplus >= 201103L) +#define UTEST_AUTO(x) const auto & +#elif !defined(__cplusplus) + +#if defined(__clang__) +/* clang-format off */ +/* had to disable clang-format here because it malforms the pragmas */ +#define UTEST_AUTO(x) \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wgnu-auto-type\"") __auto_type \ + _Pragma("clang diagnostic pop") +/* clang-format on */ +#else +#define UTEST_AUTO(x) __typeof__(x + 0) +#endif + +#else +#define UTEST_AUTO(x) typeof(x + 0) +#endif + +#if defined(__clang__) +#define UTEST_STRNCMP(x, y, size) \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wdisabled-macro-expansion\"") \ + strncmp(x, y, size) _Pragma("clang diagnostic pop") +#else +#define UTEST_STRNCMP(x, y, size) strncmp(x, y, size) +#endif + +#if defined(_MSC_VER) +#define UTEST_STRNCPY(x, y, size) strcpy_s(x, size, y) +#elif !defined(__clang__) && defined(__GNUC__) +static UTEST_INLINE char * +utest_strncpy_gcc(char *const dst, const char *const src, const size_t size) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wstringop-overflow" + return strncpy(dst, src, size); +#pragma GCC diagnostic pop +} + +#define UTEST_STRNCPY(x, y, size) utest_strncpy_gcc(x, y, size) +#else +#define UTEST_STRNCPY(x, y, size) strncpy(x, y, size) +#endif + +#define UTEST_SKIP(msg) \ + do { \ + UTEST_PRINTF(" Skipped : '%s'\n", (msg)); \ + *utest_result = UTEST_TEST_SKIPPED; \ + return; \ + } while (0) + +#if defined(__clang__) +#define UTEST_COND(x, y, cond, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wlanguage-extension-token\"") \ + _Pragma("clang diagnostic ignored \"-Wc++98-compat-pedantic\"") \ + _Pragma("clang diagnostic ignored \"-Wfloat-equal\"") \ + UTEST_AUTO(x) xEval = (x); \ + UTEST_AUTO(y) yEval = (y); \ + if (!((xEval)cond(yEval))) { \ + const char *const xAsString = #x; \ + const char *const yAsString = #y; \ + _Pragma("clang diagnostic pop") \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : ("); \ + UTEST_PRINTF("%s) " #cond " (%s", xAsString, yAsString); \ + UTEST_PRINTF(")\n"); \ + UTEST_PRINTF(" Actual : "); \ + utest_type_printer(xEval); \ + UTEST_PRINTF(" vs "); \ + utest_type_printer(yEval); \ + UTEST_PRINTF("\n"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END +#elif defined(__GNUC__) || defined(__TINYC__) +#define UTEST_COND(x, y, cond, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + UTEST_AUTO(x) xEval = (x); \ + UTEST_AUTO(y) yEval = (y); \ + if (!((xEval)cond(yEval))) { \ + const char *const xAsString = #x; \ + const char *const yAsString = #y; \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : ("); \ + UTEST_PRINTF("%s) " #cond " (%s", xAsString, yAsString); \ + UTEST_PRINTF(")\n"); \ + UTEST_PRINTF(" Actual : "); \ + utest_type_printer(xEval); \ + UTEST_PRINTF(" vs "); \ + utest_type_printer(yEval); \ + UTEST_PRINTF("\n"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END +#else +#define UTEST_COND(x, y, cond, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + if (!((x)cond(y))) { \ + UTEST_PRINTF("%s:%i: Failure (Expected " #cond " Actual)", __FILE__, \ + __LINE__); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s", msg); \ + } \ + UTEST_PRINTF("\n"); \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END +#endif + +#define EXPECT_EQ(x, y) UTEST_COND(x, y, ==, "", 0) +#define EXPECT_EQ_MSG(x, y, msg) UTEST_COND(x, y, ==, msg, 0) +#define ASSERT_EQ(x, y) UTEST_COND(x, y, ==, "", 1) +#define ASSERT_EQ_MSG(x, y, msg) UTEST_COND(x, y, ==, msg, 1) + +#define EXPECT_NE(x, y) UTEST_COND(x, y, !=, "", 0) +#define EXPECT_NE_MSG(x, y, msg) UTEST_COND(x, y, !=, msg, 0) +#define ASSERT_NE(x, y) UTEST_COND(x, y, !=, "", 1) +#define ASSERT_NE_MSG(x, y, msg) UTEST_COND(x, y, !=, msg, 1) + +#define EXPECT_LT(x, y) UTEST_COND(x, y, <, "", 0) +#define EXPECT_LT_MSG(x, y, msg) UTEST_COND(x, y, <, msg, 0) +#define ASSERT_LT(x, y) UTEST_COND(x, y, <, "", 1) +#define ASSERT_LT_MSG(x, y, msg) UTEST_COND(x, y, <, msg, 1) + +#define EXPECT_LE(x, y) UTEST_COND(x, y, <=, "", 0) +#define EXPECT_LE_MSG(x, y, msg) UTEST_COND(x, y, <=, msg, 0) +#define ASSERT_LE(x, y) UTEST_COND(x, y, <=, "", 1) +#define ASSERT_LE_MSG(x, y, msg) UTEST_COND(x, y, <=, msg, 1) + +#define EXPECT_GT(x, y) UTEST_COND(x, y, >, "", 0) +#define EXPECT_GT_MSG(x, y, msg) UTEST_COND(x, y, >, msg, 0) +#define ASSERT_GT(x, y) UTEST_COND(x, y, >, "", 1) +#define ASSERT_GT_MSG(x, y, msg) UTEST_COND(x, y, >, msg, 1) + +#define EXPECT_GE(x, y) UTEST_COND(x, y, >=, "", 0) +#define EXPECT_GE_MSG(x, y, msg) UTEST_COND(x, y, >=, msg, 0) +#define ASSERT_GE(x, y) UTEST_COND(x, y, >=, "", 1) +#define ASSERT_GE_MSG(x, y, msg) UTEST_COND(x, y, >=, msg, 1) + +#define UTEST_TRUE(x, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const int xEval = !!(x); \ + if (!(xEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : true\n"); \ + UTEST_PRINTF(" Actual : %s\n", (xEval) ? "true" : "false"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_TRUE(x) UTEST_TRUE(x, "", 0) +#define EXPECT_TRUE_MSG(x, msg) UTEST_TRUE(x, msg, 0) +#define ASSERT_TRUE(x) UTEST_TRUE(x, "", 1) +#define ASSERT_TRUE_MSG(x, msg) UTEST_TRUE(x, msg, 1) + +#define UTEST_FALSE(x, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const int xEval = !!(x); \ + if (xEval) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : false\n"); \ + UTEST_PRINTF(" Actual : %s\n", (xEval) ? "true" : "false"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_FALSE(x) UTEST_FALSE(x, "", 0) +#define EXPECT_FALSE_MSG(x, msg) UTEST_FALSE(x, msg, 0) +#define ASSERT_FALSE(x) UTEST_FALSE(x, "", 1) +#define ASSERT_FALSE_MSG(x, msg) UTEST_FALSE(x, msg, 1) + +#define UTEST_MEMEQ(x, y, size, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + UTEST_SURPRESS_WARNINGS_BEGIN \ + size_t i = 0; \ + const void *xEval = (x); \ + const void *yEval = (y); \ + const size_t sizeEval = UTEST_CAST(size_t, size); \ + if (0 != memcmp(xEval, yEval, sizeEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : "); \ + for (i = 0; i < sizeEval; ++i) { \ + const unsigned char b = \ + UTEST_PTR_CAST(const unsigned char *, xEval)[i]; \ + UTEST_PRINTF("%02X ", b); \ + } \ + UTEST_PRINTF("\n"); \ + UTEST_PRINTF(" Actual : "); \ + for (i = 0; i < sizeEval; ++i) { \ + const unsigned char b = \ + UTEST_PTR_CAST(const unsigned char *, yEval)[i]; \ + UTEST_PRINTF("%02X ", b); \ + } \ + UTEST_PRINTF("\n"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + UTEST_SURPRESS_WARNINGS_END \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_MEMEQ(x, y, s) UTEST_MEMEQ(x, y, s, "", 0) +#define EXPECT_MEMEQ_MSG(x, y, s, msg) UTEST_MEMEQ(x, y, s, msg, 0) +#define ASSERT_MEMEQ(x, y, s) UTEST_MEMEQ(x, y, s, "", 1) +#define ASSERT_MEMEQ_MSG(x, y, s, msg) UTEST_STREQ(x, y, s, msg, 1) + +#define UTEST_STREQ(x, y, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const char *xEval = (x); \ + const char *yEval = (y); \ + if (UTEST_NULL == xEval || UTEST_NULL == yEval || \ + 0 != strcmp(xEval, yEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : \"%s\"\n", xEval); \ + UTEST_PRINTF(" Actual : \"%s\"\n", yEval); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_STREQ(x, y) UTEST_STREQ(x, y, "", 0) +#define EXPECT_STREQ_MSG(x, y, msg) UTEST_STREQ(x, y, msg, 0) +#define ASSERT_STREQ(x, y) UTEST_STREQ(x, y, "", 1) +#define ASSERT_STREQ_MSG(x, y, msg) UTEST_STREQ(x, y, msg, 1) + +#define UTEST_STRNE(x, y, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const char *xEval = (x); \ + const char *yEval = (y); \ + if (UTEST_NULL == xEval || UTEST_NULL == yEval || \ + 0 == strcmp(xEval, yEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : \"%s\"\n", xEval); \ + UTEST_PRINTF(" Actual : \"%s\"\n", yEval); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_STRNE(x, y) UTEST_STRNE(x, y, "", 0) +#define EXPECT_STRNE_MSG(x, y, msg) UTEST_STRNE(x, y, msg, 0) +#define ASSERT_STRNE(x, y) UTEST_STRNE(x, y, "", 1) +#define ASSERT_STRNE_MSG(x, y, msg) UTEST_STRNE(x, y, msg, 1) + +#define UTEST_STRNEQ(x, y, n, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const char *xEval = (x); \ + const char *yEval = (y); \ + const size_t nEval = UTEST_CAST(size_t, n); \ + if (UTEST_NULL == xEval || UTEST_NULL == yEval || \ + 0 != UTEST_STRNCMP(xEval, yEval, nEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : \"%.*s\"\n", UTEST_CAST(int, nEval), xEval); \ + UTEST_PRINTF(" Actual : \"%.*s\"\n", UTEST_CAST(int, nEval), yEval); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_STRNEQ(x, y, n) UTEST_STRNEQ(x, y, n, "", 0) +#define EXPECT_STRNEQ_MSG(x, y, n, msg) UTEST_STRNEQ(x, y, n, msg, 0) +#define ASSERT_STRNEQ(x, y, n) UTEST_STRNEQ(x, y, n, "", 1) +#define ASSERT_STRNEQ_MSG(x, y, n, msg) UTEST_STRNEQ(x, y, n, msg, 1) + +#define UTEST_STRNNE(x, y, n, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const char *xEval = (x); \ + const char *yEval = (y); \ + const size_t nEval = UTEST_CAST(size_t, n); \ + if (UTEST_NULL == xEval || UTEST_NULL == yEval || \ + 0 == UTEST_STRNCMP(xEval, yEval, nEval)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : \"%.*s\"\n", UTEST_CAST(int, nEval), xEval); \ + UTEST_PRINTF(" Actual : \"%.*s\"\n", UTEST_CAST(int, nEval), yEval); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_STRNNE(x, y, n) UTEST_STRNNE(x, y, n, "", 0) +#define EXPECT_STRNNE_MSG(x, y, n, msg) UTEST_STRNNE(x, y, n, msg, 0) +#define ASSERT_STRNNE(x, y, n) UTEST_STRNNE(x, y, n, "", 1) +#define ASSERT_STRNNE_MSG(x, y, n, msg) UTEST_STRNNE(x, y, n, msg, 1) + +#define UTEST_NEAR(x, y, epsilon, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + const double diff = \ + utest_fabs(UTEST_CAST(double, x) - UTEST_CAST(double, y)); \ + if (diff > UTEST_CAST(double, epsilon) || utest_isnan(diff)) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : %f\n", UTEST_CAST(double, x)); \ + UTEST_PRINTF(" Actual : %f\n", UTEST_CAST(double, y)); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_NEAR(x, y, epsilon) UTEST_NEAR(x, y, epsilon, "", 0) +#define EXPECT_NEAR_MSG(x, y, epsilon, msg) UTEST_NEAR(x, y, epsilon, msg, 0) +#define ASSERT_NEAR(x, y, epsilon) UTEST_NEAR(x, y, epsilon, "", 1) +#define ASSERT_NEAR_MSG(x, y, epsilon, msg) UTEST_NEAR(x, y, epsilon, msg, 1) + +#if defined(UTEST_HAS_EXCEPTIONS) +#define UTEST_EXCEPTION(x, exception_type, msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + int exception_caught = 0; \ + try { \ + x; \ + } catch (const exception_type &) { \ + exception_caught = 1; \ + } catch (...) { \ + exception_caught = 2; \ + } \ + if (1 != exception_caught) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : %s exception\n", #exception_type); \ + UTEST_PRINTF(" Actual : %s\n", (2 == exception_caught) \ + ? "Unexpected exception" \ + : "No exception"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_EXCEPTION(x, exception_type) \ + UTEST_EXCEPTION(x, exception_type, "", 0) +#define EXPECT_EXCEPTION_MSG(x, exception_type, msg) \ + UTEST_EXCEPTION(x, exception_type, msg, 0) +#define ASSERT_EXCEPTION(x, exception_type) \ + UTEST_EXCEPTION(x, exception_type, "", 1) +#define ASSERT_EXCEPTION_MSG(x, exception_type, msg) \ + UTEST_EXCEPTION(x, exception_type, msg, 1) + +#define UTEST_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message, \ + msg, is_assert) \ + UTEST_SURPRESS_WARNING_BEGIN do { \ + int exception_caught = 0; \ + char *message_caught = UTEST_NULL; \ + try { \ + x; \ + } catch (const exception_type &e) { \ + const char *const what = e.what(); \ + exception_caught = 1; \ + if (0 != \ + UTEST_STRNCMP(what, exception_message, strlen(exception_message))) { \ + const size_t message_size = strlen(what) + 1; \ + message_caught = UTEST_PTR_CAST(char *, malloc(message_size)); \ + UTEST_STRNCPY(message_caught, what, message_size); \ + } \ + } catch (...) { \ + exception_caught = 2; \ + } \ + if (1 != exception_caught) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : %s exception\n", #exception_type); \ + UTEST_PRINTF(" Actual : %s\n", (2 == exception_caught) \ + ? "Unexpected exception" \ + : "No exception"); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + if (is_assert) { \ + return; \ + } \ + } else if (UTEST_NULL != message_caught) { \ + UTEST_PRINTF("%s:%i: Failure\n", __FILE__, __LINE__); \ + UTEST_PRINTF(" Expected : %s exception with message %s\n", \ + #exception_type, exception_message); \ + UTEST_PRINTF(" Actual message : %s\n", message_caught); \ + if (strlen(msg) > 0) { \ + UTEST_PRINTF(" Message : %s\n", msg); \ + } \ + *utest_result = UTEST_TEST_FAILURE; \ + free(message_caught); \ + if (is_assert) { \ + return; \ + } \ + } \ + } \ + while (0) \ + UTEST_SURPRESS_WARNING_END + +#define EXPECT_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message) \ + UTEST_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message, "", 0) +#define EXPECT_EXCEPTION_WITH_MESSAGE_MSG(x, exception_type, \ + exception_message, msg) \ + UTEST_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message, msg, 0) +#define ASSERT_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message) \ + UTEST_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message, "", 1) +#define ASSERT_EXCEPTION_WITH_MESSAGE_MSG(x, exception_type, \ + exception_message, msg) \ + UTEST_EXCEPTION_WITH_MESSAGE(x, exception_type, exception_message, msg, 1) +#endif + +#if defined(__clang__) +#if __has_warning("-Wunsafe-buffer-usage") +#define UTEST_SURPRESS_WARNINGS_BEGIN \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wunsafe-buffer-usage\"") +#define UTEST_SURPRESS_WARNINGS_END _Pragma("clang diagnostic pop") +#else +#define UTEST_SURPRESS_WARNINGS_BEGIN +#define UTEST_SURPRESS_WARNINGS_END +#endif +#elif defined(__GNUC__) && __GNUC__ >= 8 && defined(__cplusplus) +#define UTEST_SURPRESS_WARNINGS_BEGIN \ + _Pragma("GCC diagnostic push") \ + _Pragma("GCC diagnostic ignored \"-Wclass-memaccess\"") +#define UTEST_SURPRESS_WARNINGS_END _Pragma("GCC diagnostic pop") +#else +#define UTEST_SURPRESS_WARNINGS_BEGIN +#define UTEST_SURPRESS_WARNINGS_END +#endif + +#define UTEST(SET, NAME) \ + UTEST_SURPRESS_WARNINGS_BEGIN \ + UTEST_EXTERN struct utest_state_s utest_state; \ + static void utest_run_##SET##_##NAME(int *utest_result); \ + static void utest_##SET##_##NAME(int *utest_result, size_t utest_index) { \ + (void)utest_index; \ + utest_run_##SET##_##NAME(utest_result); \ + } \ + UTEST_INITIALIZER(utest_register_##SET##_##NAME) { \ + const size_t index = utest_state.tests_length++; \ + const char name_part[] = #SET "." #NAME; \ + const size_t name_size = strlen(name_part) + 1; \ + char *name = UTEST_PTR_CAST(char *, malloc(name_size)); \ + utest_state.tests = UTEST_PTR_CAST( \ + struct utest_test_state_s *, \ + utest_realloc(UTEST_PTR_CAST(void *, utest_state.tests), \ + sizeof(struct utest_test_state_s) * \ + utest_state.tests_length)); \ + if (utest_state.tests && name) { \ + utest_state.tests[index].func = &utest_##SET##_##NAME; \ + utest_state.tests[index].name = name; \ + utest_state.tests[index].index = 0; \ + UTEST_SNPRINTF(name, name_size, "%s", name_part); \ + } else { \ + if (utest_state.tests) { \ + free(utest_state.tests); \ + utest_state.tests = NULL; \ + } \ + if (name) { \ + free(name); \ + } \ + } \ + } \ + UTEST_SURPRESS_WARNINGS_END \ + void utest_run_##SET##_##NAME(int *utest_result) + +#define UTEST_F_SETUP(FIXTURE) \ + static void utest_f_setup_##FIXTURE(int *utest_result, \ + struct FIXTURE *utest_fixture) + +#define UTEST_F_TEARDOWN(FIXTURE) \ + static void utest_f_teardown_##FIXTURE(int *utest_result, \ + struct FIXTURE *utest_fixture) + +#define UTEST_F(FIXTURE, NAME) \ + UTEST_SURPRESS_WARNINGS_BEGIN \ + UTEST_EXTERN struct utest_state_s utest_state; \ + static void utest_f_setup_##FIXTURE(int *, struct FIXTURE *); \ + static void utest_f_teardown_##FIXTURE(int *, struct FIXTURE *); \ + static void utest_run_##FIXTURE##_##NAME(int *, struct FIXTURE *); \ + static void utest_f_##FIXTURE##_##NAME(int *utest_result, \ + size_t utest_index) { \ + struct FIXTURE fixture; \ + (void)utest_index; \ + memset(&fixture, 0, sizeof(fixture)); \ + utest_f_setup_##FIXTURE(utest_result, &fixture); \ + if (UTEST_TEST_PASSED != *utest_result) { \ + return; \ + } \ + utest_run_##FIXTURE##_##NAME(utest_result, &fixture); \ + utest_f_teardown_##FIXTURE(utest_result, &fixture); \ + } \ + UTEST_INITIALIZER(utest_register_##FIXTURE##_##NAME) { \ + const size_t index = utest_state.tests_length++; \ + const char name_part[] = #FIXTURE "." #NAME; \ + const size_t name_size = strlen(name_part) + 1; \ + char *name = UTEST_PTR_CAST(char *, malloc(name_size)); \ + utest_state.tests = UTEST_PTR_CAST( \ + struct utest_test_state_s *, \ + utest_realloc(UTEST_PTR_CAST(void *, utest_state.tests), \ + sizeof(struct utest_test_state_s) * \ + utest_state.tests_length)); \ + if (utest_state.tests && name) { \ + utest_state.tests[index].func = &utest_f_##FIXTURE##_##NAME; \ + utest_state.tests[index].name = name; \ + utest_state.tests[index].index = 0; \ + UTEST_SNPRINTF(name, name_size, "%s", name_part); \ + } else { \ + if (utest_state.tests) { \ + free(utest_state.tests); \ + utest_state.tests = NULL; \ + } \ + if (name) { \ + free(name); \ + } \ + } \ + } \ + UTEST_SURPRESS_WARNINGS_END \ + void utest_run_##FIXTURE##_##NAME(int *utest_result, \ + struct FIXTURE *utest_fixture) + +#define UTEST_I_SETUP(FIXTURE) \ + static void utest_i_setup_##FIXTURE( \ + int *utest_result, struct FIXTURE *utest_fixture, size_t utest_index) + +#define UTEST_I_TEARDOWN(FIXTURE) \ + static void utest_i_teardown_##FIXTURE( \ + int *utest_result, struct FIXTURE *utest_fixture, size_t utest_index) + +#define UTEST_I(FIXTURE, NAME, INDEX) \ + UTEST_SURPRESS_WARNINGS_BEGIN \ + UTEST_EXTERN struct utest_state_s utest_state; \ + static void utest_run_##FIXTURE##_##NAME##_##INDEX(int *, struct FIXTURE *); \ + static void utest_i_##FIXTURE##_##NAME##_##INDEX(int *utest_result, \ + size_t index) { \ + struct FIXTURE fixture; \ + memset(&fixture, 0, sizeof(fixture)); \ + utest_i_setup_##FIXTURE(utest_result, &fixture, index); \ + if (UTEST_TEST_PASSED != *utest_result) { \ + return; \ + } \ + utest_run_##FIXTURE##_##NAME##_##INDEX(utest_result, &fixture); \ + utest_i_teardown_##FIXTURE(utest_result, &fixture, index); \ + } \ + UTEST_INITIALIZER(utest_register_##FIXTURE##_##NAME##_##INDEX) { \ + size_t i; \ + utest_uint64_t iUp; \ + for (i = 0; i < (INDEX); i++) { \ + const size_t index = utest_state.tests_length++; \ + const char name_part[] = #FIXTURE "." #NAME; \ + const size_t name_size = strlen(name_part) + 32; \ + char *name = UTEST_PTR_CAST(char *, malloc(name_size)); \ + utest_state.tests = UTEST_PTR_CAST( \ + struct utest_test_state_s *, \ + utest_realloc(UTEST_PTR_CAST(void *, utest_state.tests), \ + sizeof(struct utest_test_state_s) * \ + utest_state.tests_length)); \ + if (utest_state.tests && name) { \ + utest_state.tests[index].func = &utest_i_##FIXTURE##_##NAME##_##INDEX; \ + utest_state.tests[index].index = i; \ + utest_state.tests[index].name = name; \ + iUp = UTEST_CAST(utest_uint64_t, i); \ + UTEST_SNPRINTF(name, name_size, "%s/%" UTEST_PRIu64, name_part, iUp); \ + } else { \ + if (utest_state.tests) { \ + free(utest_state.tests); \ + utest_state.tests = NULL; \ + } \ + if (name) { \ + free(name); \ + } \ + } \ + } \ + } \ + UTEST_SURPRESS_WARNINGS_END \ + void utest_run_##FIXTURE##_##NAME##_##INDEX(int *utest_result, \ + struct FIXTURE *utest_fixture) + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc++98-compat-pedantic" +#endif + +UTEST_WEAK +double utest_fabs(double d); +UTEST_WEAK +double utest_fabs(double d) { + union { + double d; + utest_uint64_t u; + } both; + both.d = d; + both.u &= 0x7fffffffffffffffu; + return both.d; +} + +UTEST_WEAK +int utest_isnan(double d); +UTEST_WEAK +int utest_isnan(double d) { + union { + double d; + utest_uint64_t u; + } both; + both.d = d; + both.u &= 0x7fffffffffffffffu; + return both.u > 0x7ff0000000000000u; +} + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + +#if defined(__clang__) +#if __has_warning("-Wunsafe-buffer-usage") +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunsafe-buffer-usage" +#endif +#endif + +UTEST_WEAK +int utest_should_filter_test(const char *filter, const char *testcase); +UTEST_WEAK int utest_should_filter_test(const char *filter, + const char *testcase) { + if (filter) { + const char *filter_cur = filter; + const char *testcase_cur = testcase; + const char *filter_wildcard = UTEST_NULL; + + while (('\0' != *filter_cur) && ('\0' != *testcase_cur)) { + if ('*' == *filter_cur) { + /* store the position of the wildcard */ + filter_wildcard = filter_cur; + + /* skip the wildcard character */ + filter_cur++; + + while (('\0' != *filter_cur) && ('\0' != *testcase_cur)) { + if ('*' == *filter_cur) { + /* + we found another wildcard (filter is something like *foo*) so we + exit the current loop, and return to the parent loop to handle + the wildcard case + */ + break; + } else if (*filter_cur != *testcase_cur) { + /* otherwise our filter didn't match, so reset it */ + filter_cur = filter_wildcard; + } + + /* move testcase along */ + testcase_cur++; + + /* move filter along */ + filter_cur++; + } + + if (('\0' == *filter_cur) && ('\0' == *testcase_cur)) { + return 0; + } + + /* if the testcase has been exhausted, we don't have a match! */ + if ('\0' == *testcase_cur) { + return 1; + } + } else { + if (*testcase_cur != *filter_cur) { + /* test case doesn't match filter */ + return 1; + } else { + /* move our filter and testcase forward */ + testcase_cur++; + filter_cur++; + } + } + } + + if (('\0' != *filter_cur) || + (('\0' != *testcase_cur) && + ((filter == filter_cur) || ('*' != filter_cur[-1])))) { + /* we have a mismatch! */ + return 1; + } + } + + return 0; +} + +static UTEST_INLINE FILE *utest_fopen(const char *filename, const char *mode) { +#ifdef _MSC_VER + FILE *file; + if (0 == fopen_s(&file, filename, mode)) { + return file; + } else { + return UTEST_NULL; + } +#else + return fopen(filename, mode); +#endif +} + +static UTEST_INLINE int utest_main(int argc, const char *const argv[]); +int utest_main(int argc, const char *const argv[]) { + utest_uint64_t failed = 0; + utest_uint64_t skipped = 0; + size_t index = 0; + size_t *failed_testcases = UTEST_NULL; + size_t failed_testcases_length = 0; + size_t *skipped_testcases = UTEST_NULL; + size_t skipped_testcases_length = 0; + const char *filter = UTEST_NULL; + utest_uint64_t ran_tests = 0; + int enable_mixed_units = 0; + int random_order = 0; + utest_uint32_t seed = 0; + + enum colours { RESET, GREEN, RED, YELLOW }; + + const int use_colours = UTEST_COLOUR_OUTPUT(); + const char *colours[] = {"\033[0m", "\033[32m", "\033[31m", "\033[33m"}; + + if (!use_colours) { + for (index = 0; index < sizeof colours / sizeof colours[0]; index++) { + colours[index] = ""; + } + } + /* loop through all arguments looking for our options */ + for (index = 1; index < UTEST_CAST(size_t, argc); index++) { + /* Informational switches */ + const char help_str[] = "--help"; + const char list_str[] = "--list-tests"; + /* Test config switches */ + const char filter_str[] = "--filter="; + const char output_str[] = "--output="; + const char enable_mixed_units_str[] = "--enable-mixed-units"; + const char random_order_str[] = "--random-order"; + const char random_order_with_seed_str[] = "--random-order="; + + if (0 == UTEST_STRNCMP(argv[index], help_str, strlen(help_str))) { + printf("utest.h - the single file unit testing solution for C/C++!\n" + "Command line Options:\n" + " --help Show this message and exit.\n" + " --filter= Filter the test cases to run (EG. " + "MyTest*.a would run MyTestCase.a but not MyTestCase.b).\n" + " --list-tests List testnames, one per line. Output " + "names can be passed to --filter.\n"); + printf(" --output= Output an xunit XML file to the file " + "specified in .\n" + " --enable-mixed-units Enable the per-test output to contain " + "mixed units (s/ms/us/ns).\n" + " --random-order[=] Randomize the order that the tests are " + "ran in. If the optional argument is not provided, then a " + "random starting seed is used.\n"); + goto cleanup; + } else if (0 == + UTEST_STRNCMP(argv[index], filter_str, strlen(filter_str))) { + /* user wants to filter what test cases run! */ + filter = argv[index] + strlen(filter_str); + } else if (0 == + UTEST_STRNCMP(argv[index], output_str, strlen(output_str))) { + utest_state.output = utest_fopen(argv[index] + strlen(output_str), "w+"); + } else if (0 == UTEST_STRNCMP(argv[index], list_str, strlen(list_str))) { + for (index = 0; index < utest_state.tests_length; index++) { + UTEST_PRINTF("%s\n", utest_state.tests[index].name); + } + /* when printing the test list, don't actually run the tests */ + return 0; + } else if (0 == UTEST_STRNCMP(argv[index], enable_mixed_units_str, + strlen(enable_mixed_units_str))) { + enable_mixed_units = 1; + } else if (0 == UTEST_STRNCMP(argv[index], random_order_with_seed_str, + strlen(random_order_with_seed_str))) { + seed = + UTEST_CAST(utest_uint32_t, + strtoul(argv[index] + strlen(random_order_with_seed_str), + UTEST_NULL, 10)); + random_order = 1; + } else if (0 == UTEST_STRNCMP(argv[index], random_order_str, + strlen(random_order_str))) { + const utest_int64_t ns = utest_ns(); + + // Some really poor pseudo-random using the current time. I do this + // because I really want to avoid using C's rand() because that'd mean our + // random would be affected by any srand() usage by the user (which I + // don't want). + seed = UTEST_CAST(utest_uint32_t, ns >> 32) * 31 + + UTEST_CAST(utest_uint32_t, ns & 0xffffffff); + random_order = 1; + } + } + + if (random_order) { + // Use Fisher-Yates with the Durstenfield's version to randomly re-order the + // tests. + for (index = utest_state.tests_length; index > 1; index--) { + // For the random order we'll use PCG. + const utest_uint32_t state = seed; + const utest_uint32_t word = + ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + const utest_uint32_t next = + ((word >> 22u) ^ word) % UTEST_CAST(utest_uint32_t, index); + + // Swap the randomly chosen element into the last location. + const struct utest_test_state_s copy = utest_state.tests[index - 1]; + utest_state.tests[index - 1] = utest_state.tests[next]; + utest_state.tests[next] = copy; + + // Move the seed onwards. + seed = seed * 747796405u + 2891336453u; + } + } + + for (index = 0; index < utest_state.tests_length; index++) { + if (utest_should_filter_test(filter, utest_state.tests[index].name)) { + continue; + } + + ran_tests++; + } + + printf("%s[==========]%s Running %" UTEST_PRIu64 " test cases.\n", + colours[GREEN], colours[RESET], UTEST_CAST(utest_uint64_t, ran_tests)); + + if (utest_state.output) { + fprintf(utest_state.output, "\n"); + fprintf(utest_state.output, + "\n", + UTEST_CAST(utest_uint64_t, ran_tests)); + fprintf(utest_state.output, + "\n", + UTEST_CAST(utest_uint64_t, ran_tests)); + } + + for (index = 0; index < utest_state.tests_length; index++) { + int result = UTEST_TEST_PASSED; + utest_int64_t ns = 0; + + if (utest_should_filter_test(filter, utest_state.tests[index].name)) { + continue; + } + + printf("%s[ RUN ]%s %s\n", colours[GREEN], colours[RESET], + utest_state.tests[index].name); + + if (utest_state.output) { + fprintf(utest_state.output, "", + utest_state.tests[index].name); + } + + ns = utest_ns(); + errno = 0; +#if defined(UTEST_HAS_EXCEPTIONS) + UTEST_SURPRESS_WARNING_BEGIN + try { + utest_state.tests[index].func(&result, utest_state.tests[index].index); + } catch (const std::exception &err) { + printf(" Exception : %s\n", err.what()); + result = UTEST_TEST_FAILURE; + } catch (...) { + printf(" Exception : Unknown\n"); + result = UTEST_TEST_FAILURE; + } + UTEST_SURPRESS_WARNING_END +#else + utest_state.tests[index].func(&result, utest_state.tests[index].index); +#endif + ns = utest_ns() - ns; + + if (utest_state.output) { + fprintf(utest_state.output, "\n"); + } + + // Record the failing test. + if (UTEST_TEST_FAILURE == result) { + const size_t failed_testcase_index = failed_testcases_length++; + failed_testcases = UTEST_PTR_CAST( + size_t *, utest_realloc(UTEST_PTR_CAST(void *, failed_testcases), + sizeof(size_t) * failed_testcases_length)); + if (UTEST_NULL != failed_testcases) { + failed_testcases[failed_testcase_index] = index; + } + failed++; + } else if (UTEST_TEST_SKIPPED == result) { + const size_t skipped_testcase_index = skipped_testcases_length++; + skipped_testcases = UTEST_PTR_CAST( + size_t *, utest_realloc(UTEST_PTR_CAST(void *, skipped_testcases), + sizeof(size_t) * skipped_testcases_length)); + if (UTEST_NULL != skipped_testcases) { + skipped_testcases[skipped_testcase_index] = index; + } + skipped++; + } + + { + const char *const units[] = {"ns", "us", "ms", "s", UTEST_NULL}; + unsigned int unit_index = 0; + utest_int64_t time = ns; + + if (enable_mixed_units) { + for (unit_index = 0; UTEST_NULL != units[unit_index]; unit_index++) { + if (10000 > time) { + break; + } + + time /= 1000; + } + } + + if (UTEST_TEST_FAILURE == result) { + printf("%s[ FAILED ]%s %s (%" UTEST_PRId64 "%s)\n", colours[RED], + colours[RESET], utest_state.tests[index].name, time, + units[unit_index]); + } else if (UTEST_TEST_SKIPPED == result) { + printf("%s[ SKIPPED ]%s %s (%" UTEST_PRId64 "%s)\n", colours[YELLOW], + colours[RESET], utest_state.tests[index].name, time, + units[unit_index]); + } else { + printf("%s[ OK ]%s %s (%" UTEST_PRId64 "%s)\n", colours[GREEN], + colours[RESET], utest_state.tests[index].name, time, + units[unit_index]); + } + } + } + + printf("%s[==========]%s %" UTEST_PRIu64 " test cases ran.\n", colours[GREEN], + colours[RESET], ran_tests); + printf("%s[ PASSED ]%s %" UTEST_PRIu64 " tests.\n", colours[GREEN], + colours[RESET], ran_tests - failed - skipped); + + if (0 != skipped) { + printf("%s[ SKIPPED ]%s %" UTEST_PRIu64 " tests, listed below:\n", + colours[YELLOW], colours[RESET], skipped); + for (index = 0; index < skipped_testcases_length; index++) { + printf("%s[ SKIPPED ]%s %s\n", colours[YELLOW], colours[RESET], + utest_state.tests[skipped_testcases[index]].name); + } + } + + if (0 != failed) { + printf("%s[ FAILED ]%s %" UTEST_PRIu64 " tests, listed below:\n", + colours[RED], colours[RESET], failed); + for (index = 0; index < failed_testcases_length; index++) { + printf("%s[ FAILED ]%s %s\n", colours[RED], colours[RESET], + utest_state.tests[failed_testcases[index]].name); + } + } + + if (utest_state.output) { + fprintf(utest_state.output, "\n\n"); + } + +cleanup: + for (index = 0; index < utest_state.tests_length; index++) { + free(UTEST_PTR_CAST(void *, utest_state.tests[index].name)); + } + + free(UTEST_PTR_CAST(void *, skipped_testcases)); + free(UTEST_PTR_CAST(void *, failed_testcases)); + free(UTEST_PTR_CAST(void *, utest_state.tests)); + + if (utest_state.output) { + fclose(utest_state.output); + } + + return UTEST_CAST(int, failed); +} + +#if defined(__clang__) +#if __has_warning("-Wunsafe-buffer-usage") +#pragma clang diagnostic pop +#endif +#endif + +/* + we need, in exactly one source file, define the global struct that will hold + the data we need to run utest. This macro allows the user to declare the + data without having to use the UTEST_MAIN macro, thus allowing them to write + their own main() function. +*/ +#define UTEST_STATE() struct utest_state_s utest_state = {0, 0, 0} + +/* + define a main() function to call into utest.h and start executing tests! A + user can optionally not use this macro, and instead define their own main() + function and manually call utest_main. The user must, in exactly one source + file, use the UTEST_STATE macro to declare a global struct variable that + utest requires. +*/ +#define UTEST_MAIN() \ + UTEST_STATE(); \ + int main(int argc, const char *const argv[]) { \ + return utest_main(argc, argv); \ + } + +#endif /* SHEREDOM_UTEST_H_INCLUDED */ diff --git a/tools/gen-config-html.js b/tools/gen-config-html.js new file mode 100644 index 0000000..4e393c9 --- /dev/null +++ b/tools/gen-config-html.js @@ -0,0 +1,10 @@ +// Render the offline settings page to a standalone HTML file. +// Used for previewing in a browser and for emulator testing +// (`pebble emu-app-config --file `), which provides return_to so the +// page's pebblejs://close# fallback is not needed there. +var fs = require('fs'); +var spec = require('../src/js/config'); +var build = require('../src/js/configpage'); +var out = process.argv[2] || 'build/config.html'; +fs.writeFileSync(out, build(spec, {})); +console.log('wrote ' + out); diff --git a/tools/strict-check.sh b/tools/strict-check.sh new file mode 100755 index 0000000..2ef9f11 --- /dev/null +++ b/tools/strict-check.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +# Compile every C source with the SAME flags the repebble cloud build uses, so +# build errors AND warnings are caught locally instead of in the cloud. The local +# `pebble build` relaxes these via pbl_suppress_newer_gcc_warnings(); this does not. +# +# Fails on any compiler error (-Werror class, e.g. discarded-qualifiers) OR any +# remaining warning (full codegen compile, so format-truncation etc. surface). +# +# Requires a prior `pebble build` (reuses the generated headers under build/). +# Run inside the dev shell: nix develop -c sh tools/strict-check.sh +set -eu +cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +SDK="${HOME}/.pebble-sdk/SDKs/current/sdk-core/pebble" +CC="arm-none-eabi-gcc" + +# Exact cloud flag set (see a failing cloud log): -Werror with the same +# -Wno-error= relaxations. Full compile to /dev/null so codegen warnings appear. +COMMON="-std=c99 -mcpu=cortex-m3 -mthumb -ffunction-sections -fdata-sections \ +-fcommon -g -fPIE -Os -D_TIME_H_ -Dtime_t=long -Wall -Wextra -Werror \ +-Wno-unused-parameter -Wno-error=unused-function -Wno-error=unused-variable \ +-Wno-error=builtin-declaration-mismatch -Wno-error=format-truncation \ +-Wno-error=expansion-to-defined -Wno-error=zero-length-bounds \ +-Wno-error=cast-function-type -Wno-error=unused-value -DRELEASE -DPBL_SDK_3" + +# platform | UPPER | COLOR|BW | width | height +PLATFORMS="basalt:BASALT:PBL_COLOR:144:168 +diorite:DIORITE:PBL_BW:144:168 +emery:EMERY:PBL_COLOR:200:228 +flint:FLINT:PBL_BW:144:168" + +log=$(mktemp) +fail=0 +for entry in $PLATFORMS; do + plat=${entry%%:*}; rest=${entry#*:} + upper=${rest%%:*}; rest=${rest#*:} + depth=${rest%%:*}; rest=${rest#*:} + w=${rest%%:*}; h=${rest##*:} + + [ -d "build/$plat" ] || { echo "strict-check: build/$plat missing — run 'pebble build' first" >&2; exit 2; } + + inc="-I$SDK/$plat/include -Ibuild/$plat -Ibuild/include -Ibuild/src -Isrc -Iinclude" + defs="-DPBL_PLATFORM_$upper -D$depth -DPBL_RECT -DPBL_DISPLAY_WIDTH=$w -DPBL_DISPLAY_HEIGHT=$h" + + echo "== strict-check: $plat ==" + for f in src/*.c; do + if ! $CC $COMMON $inc $defs -c "$f" -o /dev/null 2>"$log"; then + cat "$log" >&2; echo "strict-check: ERROR in $f ($plat)" >&2; fail=1 + elif grep -q "warning:" "$log"; then + cat "$log" >&2; echo "strict-check: WARNING in $f ($plat)" >&2; fail=1 + fi + done +done +rm -f "$log" + +if [ "$fail" -ne 0 ]; then + echo "strict-check: FAILED — fix the errors/warnings above before pushing to cloud." >&2 + exit 1 +fi +echo "strict-check: OK (all sources compile clean, no warnings, under cloud flags)" diff --git a/tools/test.sh b/tools/test.sh new file mode 100755 index 0000000..0bd65a2 --- /dev/null +++ b/tools/test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +# One command to gate a change the way the cloud would: +# 1. host unit tests (pure logic: layout, calendar, suntimes, timefmt, vibes, +# clock-font selection) +# 2. pebble build (also generates the per-platform headers) +# 3. strict-check: full compile of every source per platform with the cloud's +# exact -Werror flag set, failing on any error OR warning +# +# Run inside the dev shell: nix develop -c sh tools/test.sh +set -eu +cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" + +echo "== host unit tests ==" +out=$(mktemp) +gcc -std=c11 -DUNIT_TEST -Isrc -Itests tests/test_*.c \ + src/layout.c src/calendar.c src/suntimes.c src/timefmt.c src/vibes.c src/math.c \ + -lm -o "$out" +"$out" +rm -f "$out" + +echo "== pebble build ==" +pebble build >/dev/null + +echo "== strict-check (cloud flags) ==" +sh tools/strict-check.sh + +echo "ALL CHECKS PASSED" diff --git a/wscript b/wscript index e9657e5..2a2ad30 100644 --- a/wscript +++ b/wscript @@ -1,20 +1,24 @@ - -# -# This file is the default set of rules to compile a Pebble project. # -# Feel free to customize this to your needs. +# Build rules for the Timely watchface (modern Pebble SDK). # - import os.path top = '.' out = 'build' + def options(ctx): ctx.load('pebble_sdk') + def configure(ctx): ctx.load('pebble_sdk') + # Legacy code under the modern arm-none-eabi gcc: apply the SDK's own set of + # -Wno-error= relaxations so historical warnings (format/stringop truncation, + # implicit-fallthrough, ...) do not stop the build. snprintf/strncpy here are + # bounded, so these are cosmetic, not unsafe. + ctx.pbl_suppress_newer_gcc_warnings() + def build(ctx): ctx.load('pebble_sdk') @@ -22,18 +26,24 @@ def build(ctx): build_worker = os.path.exists('worker_src') binaries = [] - for p in ctx.env.TARGET_PLATFORMS: - ctx.set_env(ctx.all_envs[p]) - app_elf='{}/pebble-app.elf'.format(ctx.env.BUILD_DIR) - ctx.pbl_program(source=ctx.path.ant_glob('src/**/*.c'), - target=app_elf) + cached_env = ctx.env + for platform in ctx.env.TARGET_PLATFORMS: + ctx.env = ctx.all_envs[platform] + ctx.set_group(ctx.env.PLATFORM_NAME) + app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR) + ctx.pbl_build(source=ctx.path.ant_glob('src/**/*.c'), + target=app_elf, bin_type='app') if build_worker: - worker_elf='{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR) - binaries.append({'platform': p, 'app_elf': app_elf, 'worker_elf': worker_elf}) - ctx.pbl_worker(source=ctx.path.ant_glob('worker_src/**/*.c'), - target=worker_elf) + worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR) + binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf}) + ctx.pbl_build(source=ctx.path.ant_glob('worker_src/**/*.c'), + target=worker_elf, bin_type='worker') else: - binaries.append({'platform': p, 'app_elf': app_elf}) + binaries.append({'platform': platform, 'app_elf': app_elf}) + ctx.env = cached_env - ctx.pbl_bundle(binaries=binaries, js=ctx.path.ant_glob('src/js/**/*.js')) + ctx.set_group('bundle') + ctx.pbl_bundle(binaries=binaries, + js=ctx.path.ant_glob(['src/js/**/*.js', 'src/js/**/*.json']), + js_entry_file='src/js/app.js')