chore: initial padel scaffold + design docs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 09:58:02 +02:00
commit cad84029fd
16 changed files with 1380 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
*.prg
*.prg.debug.xml
developer_key
.DS_Store

36
CLAUDE.md Normal file
View File

@@ -0,0 +1,36 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A Garmin Connect IQ watch-app for padel, written in **Monkey C**. It is currently the default scaffold generated by the VS Code "Monkey C: New Project" command — a single view with a menu — and has not yet been customized beyond the template.
- App entry: `garmin_padelApp` (set in `manifest.xml` via `entry="garmin_padelApp"`).
- Build target: a single product, `venu445mm` (Venu 4, 45mm), with `minApiLevel="6.0.2"`.
## Build, run, and test
There is no shell-script build harness; this project is built and run through the **Monkey C VS Code extension** (Connect IQ SDK), driven from the command palette:
- **Build / run in simulator**: `Monkey C: Build for Device` then `Monkey C: Run App`, or just press `F5` (the simulator launches `connectiq` + `monkeydo`).
- **Edit build targets / products**: `Monkey C: Edit Products` or `Monkey C: Set Products by Product Category`.
- **Edit app attributes, permissions, languages**: the corresponding `Monkey C: Edit ...` palette commands. These regenerate `manifest.xml` — do not hand-edit it (it is marked generated).
- **Package for the store**: `Monkey C: Export Project` (produces a `.iq` file).
CLI equivalents (if the SDK `bin` is on PATH) use `monkeyc` to compile against a device + SDK, `connectiq`/`monkeydo` to run in the simulator, and `monkeyc --unit-test` (with a test runner view) for unit tests. There are currently no tests in this repo.
## Code architecture
Connect IQ apps follow a fixed MVC-ish lifecycle. The four `source/*.mc` files map to the standard roles:
- **`garmin-padelApp.mc`** — `AppBase` subclass. `getInitialView()` returns the initial `[View, InputDelegate]` pair. App-wide start/stop hooks live here. `getApp()` is the global accessor.
- **`garmin-padelView.mc`** — `WatchUi.View`. `onLayout` binds `Rez.Layouts.MainLayout`; `onShow`/`onUpdate`/`onHide` are the render/visibility lifecycle.
- **`garmin-padelDelegate.mc`** — `BehaviorDelegate` for the main view. `onMenu()` pushes the menu (`Rez.Menus.MainMenu`) with its delegate.
- **`garmin-padelMenuDelegate.mc`** — `MenuInputDelegate`. `onMenuItem(item as Symbol)` dispatches on menu-item symbols (`:item_1`, `:item_2`).
Key conventions:
- **Resources are referenced via the generated `Rez` namespace** (`Rez.Layouts.*`, `Rez.Menus.*`, `Rez.Strings.*`, `Rez.Drawables.*`). These symbols are generated at build time from the XML in `resources/` — you do not write them by hand.
- **`resources/`** holds the declarative UI and assets: `layouts/layout.xml`, `menus/menu.xml`, `strings/strings.xml`, `drawables/`. Menu item ids in `menu.xml` must match the `:symbol` names handled in the menu delegate, and string ids must match `@Strings.*` references.
- **`monkey.jungle`** is the build config; it currently only points at `manifest.xml`. Per-device resource overrides and source paths would be added here.
- **`bin/`** is generated build output (`.mir`, `.mbc`, `Rez.mcgen`) — never edit; safe to delete and regenerate.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
# Padel Scoring App — Design Spec
**Date:** 2026-07-02
**Target:** Garmin Connect IQ watch-app (Monkey C), product `venu445mm` (Venu 4, 45mm, round AMOLED), minApiLevel 6.0.2.
**Status:** Approved design, pre-implementation.
## 1. Purpose
A padel score tracker for the watch. It shows the live score (sets / games / points) and a
top-down court diagram whose highlighted quadrant tells you who serves the next point. Points
are recorded by swiping. No GPS, no recorded Garmin activity, no match history in v1.
## 2. Screen layout
Round screen. A top band (heart rate) and a bottom band (time) frame a central row split into
three columns: **left** = sets + games, **center** = court, **right** = current-game points.
```
♥ 142 ▮▮▮▯▯ ← heart rate + zone (top band)
S G pts
2 4 +------+------+ 15 ← your team (upper number in each column)
1 3 | | | 40 ← opponents (lower number)
+------+------+ net
| | ● ← yellow ball = next server
+------+------+
14:32 ← current time (bottom band)
```
- **Left column — sets & games.** Two mini-columns: **S** (sets) and **G** (games). A small
label (`S`, `G`) sits above each pair of numbers; under it, two stacked numbers — **your team
on top**, opponents below. Sets in **bold accent color**, games in **accent color**.
- **Right column — points.** A small `pts` label (same small font as `S`/`G`) above two stacked
numbers for the current game — your team on top, opponents below — in **white**
(`0 / 15 / 30 / 40`, and `AD` if advantage is enabled).
- **Center — court.** Simplified top-down padel court, your team the **bottom** half and
opponents the **top** half. A small **yellow ball** is drawn in the quadrant of the **next
server** (see §4); it moves as serve rotates. Nothing else is highlighted.
- **Top band — heart rate.** Current heart rate (bpm) with a **zone indicator** (the current HR
zone 15, shown as a short colored bar / zone number). Requires a heart-rate sensor reading
(see Permissions below).
- **Bottom band — time.** Current time (`HH:MM`), refreshed by a 1-second timer.
The small `S` / `G` / `pts` labels share one small font and a muted (dim grey) color so the
numbers stay dominant.
**Accent color.** A single constant (proposed: a vivid padel green/teal) used for the sets and
games numbers, so it can be changed in one place. The serve ball is **yellow** (its own constant).
**Permissions.** Reading live heart rate requires the **Sensor** permission; deriving the zone
from the user's profile requires the **UserProfile** permission. Both are added via the
`Monkey C: Edit Permissions` palette command (which regenerates `manifest.xml`).
## 3. Interaction model
**During play (gestures):**
- **Swipe up** → point to **your team**.
- **Swipe down** → point to the **opponents**.
- **Swipe left** → **undo** the last recorded point (steps back through game/set boundaries too).
**Before the first point (tap):**
- A **tap** toggles the starting server between **bottom-right** (your team serves first) and
**upper-left** (opponents serve first) — the yellow ball jumps between those two quadrants.
These are the only two options because the first point
of any game is served from the deuce (right) court, and the opponents' right-hand court maps to
the screen's upper-left. Tapping is only accepted while the match is at its very start (no points
recorded yet).
**At match end:**
- The court shows a "won by *your team* / *opponents*" state and stops accepting points; **undo
still works**. A **tap** starts a fresh match, returning to server-positioning.
## 4. Serving / court geometry
Your team = bottom half, opponents = top half. The yellow ball is drawn in the quadrant
determined by (serving team) × (serve side):
| Serving team | Serve side | Quadrant |
|--------------|------------|--------------|
| Your team | deuce (right) | bottom-right |
| Your team | ad (left) | bottom-left |
| Opponents | deuce (their right) | upper-left |
| Opponents | ad (their left) | upper-right |
**Serve side by point parity.** Even number of points played so far in the current game →
deuce (right); odd → ad (left). Game start (0-0) is always deuce.
**Serving team.**
- Normal game: one team serves the entire game; the serving team alternates after each completed game.
- The first server of the match is chosen by tap (see §3).
**Tiebreak serving** (when tiebreak is enabled and reached, see §5):
- Serve side still follows point parity: even total tiebreak points → deuce, odd → ad.
- The first server serves 1 point, then service alternates every 2 points. (Server for the point
with `P` points already played = the team given by `floor((P+1)/2) mod 2` offset from the
team due to serve at 6-6.)
## 5. Scoring rules
Rules are held in a `RulesConfig` object that the engine reads as parameters, so the future
settings screen can drive them without an engine rewrite. **v1 defaults** (all overridable later):
- **Points in a game:** `0 / 15 / 30 / 40`.
- **40-40 resolution:** **golden point** (default). Next point at 40-40 wins the game. When
`RulesConfig` is set to advantage instead, 40-40 = deuce, then advantage (`AD`), then game;
must win by two points.
- **Set:** first to **6 games, win by 2**.
- **6-6:** **7-point tiebreak** (win by 2). When tiebreak is disabled in `RulesConfig`, the set
continues until a team leads by 2 games.
- **Match:** **best of 3 sets** (first to 2 sets).
## 6. Architecture (Monkey C)
Keeps the scaffold's file-per-role split; adds two logic-only files. The scoring engine imports no
`WatchUi`/`Graphics`, so it is unit-testable in isolation.
- **`source/PadelMatch.mc`** — the scoring engine. Holds points/games/sets for both teams, the
serving team, tiebreak state, and match-over/winner. Public surface:
- `pointTo(team)` — record a point, advancing game/set/match as needed.
- `undo()` — revert the last recorded point.
- `servingQuadrant()` — returns which of the four quadrants holds the serve ball.
- `setStartingServer(team)` / toggle — only valid at match start.
- accessors for the display: points (per team, formatted), games, sets, `isMatchOver()`, `winner()`.
- **Undo strategy:** push a full state snapshot onto a stack before each `pointTo`; `undo` pops
and restores. This makes swipe-left correct across point/game/set boundaries with no special-casing.
- **`source/RulesConfig.mc`** — the rules struct (golden-point flag, sets-to-win, games-per-set,
tiebreak flag/target) plus the v1 defaults.
- **`source/CourtRenderer.mc`** — draws the top-down court and the yellow serve ball in a given
quadrant, so the view stays focused on layout. Pure drawing against a `Dc`.
- **`source/HeartRateProvider.mc`** — wraps the heart-rate sensor and zone lookup. Enables
`Sensor.SENSOR_HEARTRATE`, exposes the latest bpm, and maps it to a zone 15 using
`UserProfile.getHeartRateZones`. Returns a "no reading" state when HR is unavailable
(simulator / no strap) so the view can render a placeholder.
- **`source/garmin-padelView.mc`** — `WatchUi.View`. `onUpdate` draws the top HR band, the left
sets/games column, the center court (via `CourtRenderer`), the right points column, and the
bottom time band. A 1-second `Timer` calls `requestUpdate` to keep the clock (and HR) live.
- **`source/garmin-padelDelegate.mc`** — `BehaviorDelegate`. Maps `onSwipe` (up/down/left) and
`onTap` (pre-match server toggle; new match after match end) to the engine, then `requestUpdate`.
- **`source/garmin-padelApp.mc`** — owns the `PadelMatch`, `RulesConfig`, and `HeartRateProvider`
instances; wires the view + delegate; enables sensors on start and disables them on stop.
Resources (`resources/`): the whole screen is drawn programmatically in `onUpdate` rather than via
a static layout, since positions depend on state. `strings.xml` holds team labels and the
match-end text; the template menu can be removed or repurposed later. Sensor + UserProfile
permissions are declared in `manifest.xml` via the Edit Permissions command.
## 7. Testing
- **Engine unit tests** (`monkeyc --unit-test` with a test runner) cover `PadelMatch`:
- point → game → set → match progression (golden point and advantage modes),
- tiebreak entry at 6-6, tiebreak win-by-2,
- serving team alternation between games and serve-side parity within a game,
- tiebreak serving rotation,
- `servingQuadrant()` mapping for all four cases,
- `undo()` across point/game/set boundaries and at match start (no-op).
- Rendering (`CourtRenderer`, view, HR band) is verified in the simulator (HR via the
simulator's data-simulation, since there is no real strap).
## 8. Deferred (not in v1)
- Settings screen (drives `RulesConfig`; makes the top/bottom info slots configurable).
- Match history / saved matches.
- Resume-after-close persistence (engine state is in memory only for v1).
- Recorded Garmin activity, GPS. (Heart rate **is** in v1 as a read-only display, but the match is
still not recorded as a Garmin activity.)

35
manifest.xml Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0"?>
<!-- This is a generated file. It is highly recommended that you DO NOT edit this file. -->
<iq:manifest version="3" xmlns:iq="http://www.garmin.com/xml/connectiq">
<!--
Use "Monkey C: Edit Application" from the Visual Studio Code command palette
to update the application attributes.
-->
<iq:application id="0e742fe4-203e-4dbe-a0c0-5dcb26aac685" type="watch-app" name="@Strings.AppName" entry="garmin_padelApp" launcherIcon="@Drawables.LauncherIcon" minApiLevel="6.0.2">
<!--
Use the following from the Visual Studio Code comand palette to edit
the build targets:
"Monkey C: Set Products by Product Category" - Lets you add all products
that belong to the same product category
"Monkey C: Edit Products" - Lets you add or remove any product
-->
<iq:products>
<iq:product id="venu445mm"/>
</iq:products>
<!--
Use "Monkey C: Edit Permissions" from the Visual Studio Code command
palette to update permissions.
-->
<iq:permissions/>
<!--
Use "Monkey C: Edit Languages" from the Visual Studio Code command
palette to edit your compatible language list.
-->
<iq:languages/>
<!--
Use "Monkey C: Configure Monkey Barrel" from the Visual Studio Code
command palette to edit the included barrels.
-->
<iq:barrels/>
</iq:application>
</iq:manifest>

1
monkey.jungle Normal file
View File

@@ -0,0 +1 @@
project.manifest = manifest.xml

View File

@@ -0,0 +1,3 @@
<drawables xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
<bitmap id="LauncherIcon" filename="launcher_icon.svg" dithering="none" />
</drawables>

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#000000"/>
<path d="M3 4C3 3.44772 3.44772 3 4 3H10C10.5523 3 11 3.44772 11 4V10C11 10.5523 10.5523 11 10 11H4C3.44772 11 3 10.5523 3 10V4Z" fill="#F4F4F4"/>
<path d="M3 14C3 13.4477 3.44772 13 4 13H10C10.5523 13 11 13.4477 11 14V20C11 20.5523 10.5523 21 10 21H4C3.44772 21 3 20.5523 3 20V14Z" fill="#F4F4F4"/>
<path d="M13 4C13 3.44772 13.4477 3 14 3H20C20.5523 3 21 3.44772 21 4V10C21 10.5523 20.5523 11 20 11H14C13.4477 11 13 10.5523 13 10V4Z" fill="#F4F4F4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3 14.3V19.7H19.7V14.3H14.3ZM14 13C13.4477 13 13 13.4477 13 14V20C13 20.5523 13.4477 21 14 21H20C20.5523 21 21 20.5523 21 20V14C21 13.4477 20.5523 13 20 13H14Z" fill="#F4F4F4"/>
</svg>

After

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<layout id="MainLayout" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
<label x="center" y="5" text="@Strings.prompt" color="Graphics.COLOR_WHITE" justification="Graphics.TEXT_JUSTIFY_CENTER" />
<bitmap id="id_monkey" x="center" y="30" filename="../drawables/monkey.png" />
</layout>

4
resources/menus/menu.xml Normal file
View File

@@ -0,0 +1,4 @@
<menu id="MainMenu" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
<menu-item id="item_1" label="@Strings.menu_label_1" />
<menu-item id="item_2" label="@Strings.menu_label_2" />
</menu>

View File

@@ -0,0 +1,8 @@
<strings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
<string id="AppName">garmin-padel</string>
<string id="prompt">Click the menu button</string>
<string id="menu_label_1">Item 1</string>
<string id="menu_label_2">Item 2</string>
</strings>

28
source/garmin-padelApp.mc Normal file
View File

@@ -0,0 +1,28 @@
import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
class garmin_padelApp extends Application.AppBase {
function initialize() {
AppBase.initialize();
}
// onStart() is called on application start up
function onStart(state as Dictionary?) as Void {
}
// onStop() is called when your application is exiting
function onStop(state as Dictionary?) as Void {
}
// Return the initial view of your application here
function getInitialView() as [Views] or [Views, InputDelegates] {
return [ new garmin_padelView(), new garmin_padelDelegate() ];
}
}
function getApp() as garmin_padelApp {
return Application.getApp() as garmin_padelApp;
}

View File

@@ -0,0 +1,15 @@
import Toybox.Lang;
import Toybox.WatchUi;
class garmin_padelDelegate extends WatchUi.BehaviorDelegate {
function initialize() {
BehaviorDelegate.initialize();
}
function onMenu() as Boolean {
WatchUi.pushView(new Rez.Menus.MainMenu(), new garmin_padelMenuDelegate(), WatchUi.SLIDE_UP);
return true;
}
}

View File

@@ -0,0 +1,19 @@
import Toybox.Lang;
import Toybox.System;
import Toybox.WatchUi;
class garmin_padelMenuDelegate extends WatchUi.MenuInputDelegate {
function initialize() {
MenuInputDelegate.initialize();
}
function onMenuItem(item as Symbol) as Void {
if (item == :item_1) {
System.println("item 1");
} else if (item == :item_2) {
System.println("item 2");
}
}
}

View File

@@ -0,0 +1,33 @@
import Toybox.Graphics;
import Toybox.WatchUi;
class garmin_padelView extends WatchUi.View {
function initialize() {
View.initialize();
}
// Load your resources here
function onLayout(dc as Dc) as Void {
setLayout(Rez.Layouts.MainLayout(dc));
}
// Called when this View is brought to the foreground. Restore
// the state of this View and prepare it to be shown. This includes
// loading resources into memory.
function onShow() as Void {
}
// Update the view
function onUpdate(dc as Dc) as Void {
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
}
// Called when this View is removed from the screen. Save the
// state of this View here. This includes freeing resources from
// memory.
function onHide() as Void {
}
}