1017 lines
33 KiB
Markdown
1017 lines
33 KiB
Markdown
# Padel Scoring App Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Turn the Connect IQ scaffold into a padel score tracker: a top-down court with a yellow serve ball, sets/games on the left, points on the right, heart rate on top, time on bottom, driven by swipe/tap gestures.
|
|
|
|
**Architecture:** A UI-free scoring engine (`PadelMatch`) holds all match state and rules and is fully unit-tested. UI classes (`CourtRenderer`, `HeartRateProvider`, the `View`, the `Delegate`) read from the engine and are verified in the simulator. Rules live in a `RulesConfig` object so a future settings screen can drive them.
|
|
|
|
**Tech Stack:** Monkey C, Connect IQ SDK 9.2.0, product `venu445mm` (Venu 4, 45mm), Toybox `WatchUi`/`Graphics`/`Sensor`/`UserProfile`/`Test`.
|
|
|
|
---
|
|
|
|
## Reference: full spec
|
|
|
|
Design spec lives at `docs/superpowers/specs/2026-07-02-padel-scoring-design.md`. Read it before starting.
|
|
|
|
## File structure
|
|
|
|
- `source/RulesConfig.mc` — rules struct + v1 defaults (golden point, best-of-3, tiebreak at 6-6).
|
|
- `source/PadelMatch.mc` — the scoring engine (no UI imports). Module-level `US`/`THEM` and quadrant enum live here.
|
|
- `source/PadelMatchTest.mc` — unit tests + plain helper functions for the engine.
|
|
- `source/CourtRenderer.mc` — draws the court + yellow serve ball against a `Dc`.
|
|
- `source/HeartRateProvider.mc` — wraps the HR sensor + zone lookup.
|
|
- `source/garmin-padelView.mc` — draws the whole screen in `onUpdate`; 1s timer for the clock.
|
|
- `source/garmin-padelDelegate.mc` — maps swipes/taps to the engine.
|
|
- `source/garmin-padelApp.mc` — owns the engine/rules/HR, wires view+delegate, enables sensors.
|
|
- `source/garmin-padelMenuDelegate.mc` — **deleted** (scaffold menu not used).
|
|
- `resources/strings/strings.xml` — team labels + match-end text.
|
|
- `manifest.xml` — regenerated to add `Sensor` + `UserProfile` permissions.
|
|
|
|
## Conventions used throughout
|
|
|
|
- Teams: module constants `US = 0`, `THEM = 1`.
|
|
- Quadrants: module enum `QUAD_BOTTOM_RIGHT, QUAD_BOTTOM_LEFT, QUAD_UPPER_LEFT, QUAD_UPPER_RIGHT` (values 0..3).
|
|
- Your team is the bottom half of the court; opponents the top.
|
|
|
|
---
|
|
|
|
## Task 0: Prerequisites — env vars, git, gitignore
|
|
|
|
**Files:**
|
|
- Create: `.gitignore`
|
|
|
|
- [ ] **Step 1: Set SDK env vars (run in every shell you use for this plan)**
|
|
|
|
```bash
|
|
export SDK="$HOME/Library/Application Support/Garmin/ConnectIQ/Sdks/connectiq-sdk-mac-9.2.0-2026-06-09-92a1605b2"
|
|
export KEY="$HOME/Downloads/developer_key"
|
|
export DEVICE=venu445mm
|
|
```
|
|
|
|
Verify: `"$SDK/bin/monkeyc" --version` prints a version string.
|
|
|
|
- [ ] **Step 2: Create `.gitignore`**
|
|
|
|
```
|
|
bin/
|
|
*.prg
|
|
```
|
|
|
|
- [ ] **Step 3: Initialize git and commit the current scaffold**
|
|
|
|
```bash
|
|
cd /Users/kriss/Dev/scm/scm.vilanet.fr/kriss/garmin-padel
|
|
git init
|
|
git add -A
|
|
git commit -m "chore: initial padel scaffold + design docs"
|
|
```
|
|
|
|
Expected: a commit is created (the `bin/` output is excluded).
|
|
|
|
---
|
|
|
|
## Task 1: RulesConfig
|
|
|
|
**Files:**
|
|
- Create: `source/RulesConfig.mc`
|
|
|
|
- [ ] **Step 1: Write `RulesConfig.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
|
|
// Configurable padel rules. v1 uses the defaults below; a future settings
|
|
// screen will mutate an instance of this class — the engine reads it as-is.
|
|
class RulesConfig {
|
|
public var goldenPoint as Boolean; // true = sudden death at 40-40
|
|
public var gamesPerSet as Number; // games needed to take a set (before win-by-2)
|
|
public var setsToWin as Number; // sets needed to win the match
|
|
public var tiebreakEnabled as Boolean; // play a tiebreak at gamesPerSet-all
|
|
public var tiebreakTarget as Number; // points needed to win a tiebreak (before win-by-2)
|
|
|
|
public function initialize() {
|
|
goldenPoint = true;
|
|
gamesPerSet = 6;
|
|
setsToWin = 2;
|
|
tiebreakEnabled = true;
|
|
tiebreakTarget = 7;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add source/RulesConfig.mc
|
|
git commit -m "feat: add RulesConfig with v1 defaults"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Engine test suite (test-first)
|
|
|
|
Write the complete unit-test suite for `PadelMatch` before the engine exists. It will fail to compile (no `PadelMatch`) — that is the expected failing state.
|
|
|
|
**Files:**
|
|
- Create: `source/PadelMatchTest.mc`
|
|
|
|
- [ ] **Step 1: Write `PadelMatchTest.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
import Toybox.Test;
|
|
|
|
// ---- plain helpers (not annotated :test, so not run as cases) ----
|
|
|
|
function newMatch() as PadelMatch {
|
|
return new PadelMatch(new RulesConfig());
|
|
}
|
|
|
|
function newAdvantageMatch() as PadelMatch {
|
|
var r = new RulesConfig();
|
|
r.goldenPoint = false;
|
|
return new PadelMatch(r);
|
|
}
|
|
|
|
// Win one game for `team` under golden-point rules (4 straight points).
|
|
function winGameFor(m as PadelMatch, team as Number) as Void {
|
|
for (var i = 0; i < 4; i += 1) { m.pointTo(team); }
|
|
}
|
|
|
|
// ---- test cases ----
|
|
|
|
(:test) function testInitialState(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
Test.assertEqualMessage(m.sets(US), 0, "sets US");
|
|
Test.assertEqualMessage(m.games(US), 0, "games US");
|
|
Test.assertEqualMessage(m.pointLabel(US), "0", "points US");
|
|
Test.assertEqualMessage(m.isMatchOver(), false, "not over");
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_RIGHT, "US serves deuce");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testPointLabelsProgress(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.pointLabel(US), "15", "15");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.pointLabel(US), "30", "30");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.pointLabel(US), "40", "40");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testGoldenGameWin(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
winGameFor(m, US);
|
|
Test.assertEqualMessage(m.games(US), 1, "US won a game");
|
|
Test.assertEqualMessage(m.pointLabel(US), "0", "points reset");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testAdvantageDeuceCycle(logger as Logger) as Boolean {
|
|
var m = newAdvantageMatch();
|
|
// 3-3 deuce
|
|
for (var i = 0; i < 3; i += 1) { m.pointTo(US); m.pointTo(THEM); }
|
|
Test.assertEqualMessage(m.pointLabel(US), "40", "deuce US 40");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.pointLabel(US), "AD", "US advantage");
|
|
m.pointTo(THEM);
|
|
Test.assertEqualMessage(m.pointLabel(US), "40", "back to deuce");
|
|
m.pointTo(US); m.pointTo(US);
|
|
Test.assertEqualMessage(m.games(US), 1, "US won from advantage");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testWinSetSixLove(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
for (var g = 0; g < 6; g += 1) { winGameFor(m, US); }
|
|
Test.assertEqualMessage(m.sets(US), 1, "US won the set");
|
|
Test.assertEqualMessage(m.games(US), 0, "games reset");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testTiebreakEntryAndWin(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
for (var i = 0; i < 6; i += 1) { winGameFor(m, US); winGameFor(m, THEM); }
|
|
Test.assertEqualMessage(m.isInTiebreak(), true, "6-6 tiebreak");
|
|
Test.assertEqualMessage(m.games(US), 6, "games 6-6");
|
|
for (var p = 0; p < 7; p += 1) { m.pointTo(US); }
|
|
Test.assertEqualMessage(m.isInTiebreak(), false, "tiebreak done");
|
|
Test.assertEqualMessage(m.sets(US), 1, "US won the set via tiebreak");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testServeSideParity(logger as Logger) as Boolean {
|
|
var m = newMatch(); // US serving
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_RIGHT, "0 pts deuce");
|
|
m.pointTo(THEM); // 1 point played
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_LEFT, "1 pt ad");
|
|
m.pointTo(US); // 2 points played
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_RIGHT, "2 pts deuce");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testServerAlternation(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
winGameFor(m, US); // serve passes to THEM
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_UPPER_LEFT, "THEM serves deuce");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testTiebreakServing(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
for (var i = 0; i < 6; i += 1) { winGameFor(m, US); winGameFor(m, THEM); }
|
|
// 12 games played, start server US -> US serves first in tiebreak
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_RIGHT, "P0 US deuce");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_UPPER_RIGHT, "P1 THEM ad");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_UPPER_LEFT, "P2 THEM deuce");
|
|
m.pointTo(US);
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_BOTTOM_LEFT, "P3 US ad");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testUndoAcrossGame(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
winGameFor(m, US); // US 1 game, points 0-0, THEM to serve
|
|
m.pointTo(THEM); // THEM has 15
|
|
m.undo(); // back to just-after-game
|
|
Test.assertEqualMessage(m.pointLabel(THEM), "0", "point undone");
|
|
m.undo(); // undo the game-winning point
|
|
Test.assertEqualMessage(m.games(US), 0, "game undone");
|
|
Test.assertEqualMessage(m.pointLabel(US), "40", "back to 40");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testUndoAtStartIsNoop(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
m.undo();
|
|
Test.assertEqualMessage(m.games(US), 0, "still zero");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testStartingServerToggle(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
m.toggleStartingServer();
|
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_UPPER_LEFT, "THEM serves first");
|
|
m.pointTo(US); // match started; toggle now locked
|
|
m.toggleStartingServer();
|
|
Test.assertEqualMessage(m.canSetServer(), false, "server locked after first point");
|
|
return true;
|
|
}
|
|
|
|
(:test) function testMatchOver(logger as Logger) as Boolean {
|
|
var m = newMatch();
|
|
for (var s = 0; s < 2; s += 1) {
|
|
for (var g = 0; g < 6; g += 1) { winGameFor(m, US); }
|
|
}
|
|
Test.assertEqualMessage(m.isMatchOver(), true, "match over");
|
|
Test.assertEqualMessage(m.winner(), US, "US wins");
|
|
var setsBefore = m.sets(US);
|
|
m.pointTo(US); // ignored after match over
|
|
Test.assertEqualMessage(m.sets(US), setsBefore, "no scoring after match");
|
|
return true;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build the test binary and confirm it FAILS to compile**
|
|
|
|
```bash
|
|
"$SDK/bin/monkeyc" -f monkey.jungle -o bin/test.prg -y "$KEY" -d "$DEVICE" --unit-test
|
|
```
|
|
|
|
Expected: FAIL — errors like `Undefined symbol :PadelMatch` / `US` / `QUAD_BOTTOM_RIGHT`. This confirms the tests reference the not-yet-written engine.
|
|
|
|
- [ ] **Step 3: Commit the tests**
|
|
|
|
```bash
|
|
git add source/PadelMatchTest.mc
|
|
git commit -m "test: add PadelMatch engine test suite (failing)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: PadelMatch engine
|
|
|
|
Implement the engine so every test in Task 2 passes.
|
|
|
|
**Files:**
|
|
- Create: `source/PadelMatch.mc`
|
|
|
|
- [ ] **Step 1: Write `PadelMatch.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
|
|
// Teams
|
|
const US = 0;
|
|
const THEM = 1;
|
|
|
|
// Court quadrants (your team = bottom half)
|
|
enum {
|
|
QUAD_BOTTOM_RIGHT, // US, deuce (right)
|
|
QUAD_BOTTOM_LEFT, // US, ad (left)
|
|
QUAD_UPPER_LEFT, // THEM, deuce (their right -> screen left)
|
|
QUAD_UPPER_RIGHT // THEM, ad (their left -> screen right)
|
|
}
|
|
|
|
class PadelMatch {
|
|
private var _rules as RulesConfig;
|
|
|
|
private var _points as Array<Number>; // raw point count per team in the current game
|
|
private var _games as Array<Number>; // games won in the current set
|
|
private var _sets as Array<Number>; // sets won in the match
|
|
private var _server as Number; // team serving the current game
|
|
private var _inTiebreak as Boolean;
|
|
private var _tbPoints as Array<Number>;
|
|
private var _tbFirstServer as Number; // team that served the tiebreak's first point
|
|
private var _matchOver as Boolean;
|
|
private var _winner as Number; // US / THEM / -1
|
|
private var _history as Array<Dictionary>;
|
|
|
|
private const POINT_LABELS = ["0", "15", "30", "40"];
|
|
|
|
public function initialize(rules as RulesConfig) {
|
|
_rules = rules;
|
|
_points = [0, 0];
|
|
_games = [0, 0];
|
|
_sets = [0, 0];
|
|
_server = US;
|
|
_inTiebreak = false;
|
|
_tbPoints = [0, 0];
|
|
_tbFirstServer = US;
|
|
_matchOver = false;
|
|
_winner = -1;
|
|
_history = [];
|
|
}
|
|
|
|
// ---- public API ----
|
|
|
|
public function pointTo(team as Number) as Void {
|
|
if (_matchOver) { return; }
|
|
_pushHistory();
|
|
if (_inTiebreak) {
|
|
_tbPoints[team] += 1;
|
|
_resolveTiebreak(team);
|
|
} else {
|
|
_points[team] += 1;
|
|
_resolveGame(team);
|
|
}
|
|
}
|
|
|
|
public function undo() as Void {
|
|
var n = _history.size();
|
|
if (n == 0) { return; }
|
|
var s = _history[n - 1];
|
|
_history = _history.slice(0, n - 1);
|
|
_points = s["points"] as Array<Number>;
|
|
_games = s["games"] as Array<Number>;
|
|
_sets = s["sets"] as Array<Number>;
|
|
_server = s["server"] as Number;
|
|
_inTiebreak = s["inTiebreak"] as Boolean;
|
|
_tbPoints = s["tbPoints"] as Array<Number>;
|
|
_tbFirstServer = s["tbFirstServer"] as Number;
|
|
_matchOver = s["matchOver"] as Boolean;
|
|
_winner = s["winner"] as Number;
|
|
}
|
|
|
|
public function canSetServer() as Boolean {
|
|
return _history.size() == 0 && !_matchOver;
|
|
}
|
|
|
|
public function toggleStartingServer() as Void {
|
|
if (canSetServer()) { _server = 1 - _server; }
|
|
}
|
|
|
|
public function servingQuadrant() as Number {
|
|
var team;
|
|
var pointsPlayed;
|
|
if (_inTiebreak) {
|
|
pointsPlayed = _tbPoints[0] + _tbPoints[1];
|
|
team = _tiebreakServer(pointsPlayed);
|
|
} else {
|
|
pointsPlayed = _points[0] + _points[1];
|
|
team = _server;
|
|
}
|
|
var isDeuce = (pointsPlayed % 2) == 0;
|
|
return _quadrantFor(team, isDeuce);
|
|
}
|
|
|
|
public function pointLabel(team as Number) as String {
|
|
if (_inTiebreak) { return _tbPoints[team].toString(); }
|
|
var a = _points[team];
|
|
var b = _points[1 - team];
|
|
if (!_rules.goldenPoint && a == 4 && b == 3) { return "AD"; }
|
|
if (a >= 4) { return "40"; } // safety; game normally ends before display
|
|
return POINT_LABELS[a];
|
|
}
|
|
|
|
public function games(team as Number) as Number { return _games[team]; }
|
|
public function sets(team as Number) as Number { return _sets[team]; }
|
|
public function server() as Number { return _server; }
|
|
public function isInTiebreak() as Boolean { return _inTiebreak; }
|
|
public function isMatchOver() as Boolean { return _matchOver; }
|
|
public function winner() as Number { return _winner; }
|
|
public function tiebreakPoints(team as Number) as Number { return _tbPoints[team]; }
|
|
|
|
// ---- internals ----
|
|
|
|
private function _resolveGame(team as Number) as Void {
|
|
var other = 1 - team;
|
|
var a = _points[team];
|
|
var b = _points[other];
|
|
if (_rules.goldenPoint) {
|
|
if (a >= 4) { _winGame(team); }
|
|
} else {
|
|
if (a >= 4 && (a - b) >= 2) { _winGame(team); }
|
|
else if (a >= 4 && b >= 4) { _points[team] = 3; _points[other] = 3; } // both past 40 -> deuce
|
|
}
|
|
}
|
|
|
|
private function _winGame(team as Number) as Void {
|
|
_games[team] += 1;
|
|
_points = [0, 0];
|
|
_server = 1 - _server;
|
|
_resolveSet(team);
|
|
}
|
|
|
|
private function _resolveSet(team as Number) as Void {
|
|
var other = 1 - team;
|
|
var a = _games[team];
|
|
var b = _games[other];
|
|
if (a >= _rules.gamesPerSet && (a - b) >= 2) {
|
|
_winSet(team);
|
|
} else if (_rules.tiebreakEnabled && a == _rules.gamesPerSet && b == _rules.gamesPerSet) {
|
|
_inTiebreak = true;
|
|
_tbPoints = [0, 0];
|
|
_tbFirstServer = _server; // team due to serve after the 12th game
|
|
}
|
|
}
|
|
|
|
private function _resolveTiebreak(team as Number) as Void {
|
|
var other = 1 - team;
|
|
var a = _tbPoints[team];
|
|
var b = _tbPoints[other];
|
|
if (a >= _rules.tiebreakTarget && (a - b) >= 2) {
|
|
_games[team] += 1; // e.g. 7-6
|
|
_winSet(team);
|
|
}
|
|
}
|
|
|
|
private function _winSet(team as Number) as Void {
|
|
_sets[team] += 1;
|
|
_games = [0, 0];
|
|
if (_inTiebreak) {
|
|
_inTiebreak = false;
|
|
_server = 1 - _tbFirstServer; // tiebreak receiver serves first next set
|
|
}
|
|
_resolveMatch(team);
|
|
}
|
|
|
|
private function _resolveMatch(team as Number) as Void {
|
|
if (_sets[team] >= _rules.setsToWin) {
|
|
_matchOver = true;
|
|
_winner = team;
|
|
}
|
|
}
|
|
|
|
private function _tiebreakServer(pointsPlayed as Number) as Number {
|
|
var offset = ((pointsPlayed + 1) / 2) % 2; // integer division
|
|
return (offset == 0) ? _tbFirstServer : (1 - _tbFirstServer);
|
|
}
|
|
|
|
private function _quadrantFor(team as Number, isDeuce as Boolean) as Number {
|
|
if (team == US) {
|
|
return isDeuce ? QUAD_BOTTOM_RIGHT : QUAD_BOTTOM_LEFT;
|
|
}
|
|
return isDeuce ? QUAD_UPPER_LEFT : QUAD_UPPER_RIGHT;
|
|
}
|
|
|
|
private function _pushHistory() as Void {
|
|
_history.add({
|
|
"points" => [_points[0], _points[1]],
|
|
"games" => [_games[0], _games[1]],
|
|
"sets" => [_sets[0], _sets[1]],
|
|
"server" => _server,
|
|
"inTiebreak" => _inTiebreak,
|
|
"tbPoints" => [_tbPoints[0], _tbPoints[1]],
|
|
"tbFirstServer" => _tbFirstServer,
|
|
"matchOver" => _matchOver,
|
|
"winner" => _winner
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build the test binary (should now compile)**
|
|
|
|
```bash
|
|
"$SDK/bin/monkeyc" -f monkey.jungle -o bin/test.prg -y "$KEY" -d "$DEVICE" --unit-test
|
|
```
|
|
|
|
Expected: `BUILD SUCCESSFUL`.
|
|
|
|
- [ ] **Step 3: Run the unit tests in the simulator**
|
|
|
|
```bash
|
|
"$SDK/bin/connectiq" &
|
|
"$SDK/bin/monkeydo" bin/test.prg "$DEVICE" -t
|
|
```
|
|
|
|
Expected: the console lists each `test...` case as `PASSED` and ends with a summary reporting `PASSED` for all cases and `FAILED` count 0. If any case fails, fix the engine (not the tests) and re-run.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add source/PadelMatch.mc
|
|
git commit -m "feat: implement PadelMatch scoring engine (tests pass)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: CourtRenderer
|
|
|
|
Pure drawing. Verified in the simulator (Task 8), but written and compiled now.
|
|
|
|
**Files:**
|
|
- Create: `source/CourtRenderer.mc`
|
|
|
|
- [ ] **Step 1: Write `CourtRenderer.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
import Toybox.Graphics;
|
|
|
|
// Draws a top-down padel court centered at (cx, cy) and a yellow serve ball
|
|
// in the given quadrant. Half-width HW and half-height HH keep it inside the
|
|
// central band of the round screen.
|
|
class CourtRenderer {
|
|
private const HW = 60; // half court width (px)
|
|
private const HH = 78; // half court height (px)
|
|
private const BALL_R = 6;
|
|
|
|
public function draw(dc as Graphics.Dc, cx as Number, cy as Number,
|
|
quadrant as Number, showBall as Boolean) as Void {
|
|
var left = cx - HW;
|
|
var top = cy - HH;
|
|
|
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
|
dc.setPenWidth(2);
|
|
dc.drawRectangle(left, top, 2 * HW, 2 * HH); // outer court
|
|
dc.drawLine(cx, top, cx, cy + HH); // center service line
|
|
|
|
dc.setPenWidth(1);
|
|
dc.drawLine(left, cy - HH / 2, left + 2 * HW, cy - HH / 2); // upper service line
|
|
dc.drawLine(left, cy + HH / 2, left + 2 * HW, cy + HH / 2); // lower service line
|
|
|
|
dc.setPenWidth(3);
|
|
dc.drawLine(left, cy, left + 2 * HW, cy); // net
|
|
|
|
if (showBall) {
|
|
var pos = _ballPos(quadrant, cx, cy);
|
|
dc.setColor(Graphics.COLOR_YELLOW, Graphics.COLOR_TRANSPARENT);
|
|
dc.fillCircle(pos[0], pos[1], BALL_R);
|
|
}
|
|
}
|
|
|
|
private function _ballPos(quadrant as Number, cx as Number, cy as Number) as Array<Number> {
|
|
var dx = HW / 2;
|
|
var dy = HH / 2;
|
|
if (quadrant == QUAD_BOTTOM_RIGHT) { return [cx + dx, cy + dy]; }
|
|
if (quadrant == QUAD_BOTTOM_LEFT) { return [cx - dx, cy + dy]; }
|
|
if (quadrant == QUAD_UPPER_LEFT) { return [cx - dx, cy - dy]; }
|
|
return [cx + dx, cy - dy]; // QUAD_UPPER_RIGHT
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add source/CourtRenderer.mc
|
|
git commit -m "feat: add CourtRenderer (court + yellow serve ball)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: HeartRateProvider
|
|
|
|
**Files:**
|
|
- Create: `source/HeartRateProvider.mc`
|
|
|
|
- [ ] **Step 1: Write `HeartRateProvider.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
import Toybox.Sensor;
|
|
import Toybox.UserProfile;
|
|
|
|
// Wraps the heart-rate sensor and maps the current bpm to a zone (1..5).
|
|
// Returns null bpm when no reading is available (e.g. simulator without data
|
|
// simulation), so the view can show a placeholder.
|
|
class HeartRateProvider {
|
|
private var _hr as Number?;
|
|
private var _zones as Array<Number>?;
|
|
|
|
public function initialize() {
|
|
_hr = null;
|
|
_zones = null;
|
|
}
|
|
|
|
public function onStart() as Void {
|
|
Sensor.setEnabledSensors([Sensor.SENSOR_HEARTRATE]);
|
|
Sensor.enableSensorEvents(method(:onSensor));
|
|
_zones = UserProfile.getHeartRateZones(UserProfile.HR_ZONE_SPORT_GENERIC);
|
|
}
|
|
|
|
public function onStop() as Void {
|
|
Sensor.enableSensorEvents(null);
|
|
}
|
|
|
|
public function onSensor(info as Sensor.Info) as Void {
|
|
_hr = info.heartRate;
|
|
}
|
|
|
|
public function getHeartRate() as Number? {
|
|
return _hr;
|
|
}
|
|
|
|
// Returns 1..5, or 0 if HR/zones unavailable.
|
|
public function getZone() as Number {
|
|
if (_hr == null || _zones == null) { return 0; }
|
|
var z = _zones as Array<Number>;
|
|
var hr = _hr as Number;
|
|
// _zones holds 6 boundaries: [z1lo, z2lo, z3lo, z4lo, z5lo, max]
|
|
for (var i = z.size() - 2; i >= 0; i -= 1) {
|
|
if (hr >= z[i]) { return i + 1 > 5 ? 5 : i + 1; }
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build a normal (non-test) binary to typecheck the new file**
|
|
|
|
```bash
|
|
"$SDK/bin/monkeyc" -f monkey.jungle -o bin/garmin-padel.prg -y "$KEY" -d "$DEVICE"
|
|
```
|
|
|
|
Expected: `BUILD SUCCESSFUL` (the app still uses the old scaffold view/delegate at this point; those are replaced next). If the build complains about `HR_ZONE_SPORT_GENERIC`, confirm the constant name against the API docs for SDK 9.2.0 and adjust.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add source/HeartRateProvider.mc
|
|
git commit -m "feat: add HeartRateProvider (bpm + HR zone)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: App wiring — own the engine, rules, HR
|
|
|
|
Replace the scaffold app so it owns shared state and exposes it to the view/delegate.
|
|
|
|
**Files:**
|
|
- Modify: `source/garmin-padelApp.mc`
|
|
|
|
- [ ] **Step 1: Rewrite `garmin-padelApp.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Application;
|
|
import Toybox.Lang;
|
|
import Toybox.WatchUi;
|
|
|
|
class garmin_padelApp extends Application.AppBase {
|
|
private var _rules as RulesConfig;
|
|
private var _match as PadelMatch;
|
|
private var _hr as HeartRateProvider;
|
|
|
|
public function initialize() {
|
|
AppBase.initialize();
|
|
_rules = new RulesConfig();
|
|
_match = new PadelMatch(_rules);
|
|
_hr = new HeartRateProvider();
|
|
}
|
|
|
|
public function onStart(state as Dictionary?) as Void {
|
|
_hr.onStart();
|
|
}
|
|
|
|
public function onStop(state as Dictionary?) as Void {
|
|
_hr.onStop();
|
|
}
|
|
|
|
public function getMatch() as PadelMatch { return _match; }
|
|
public function getHeartRate() as HeartRateProvider { return _hr; }
|
|
|
|
public function resetMatch() as Void {
|
|
_match = new PadelMatch(_rules);
|
|
}
|
|
|
|
public 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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add source/garmin-padelApp.mc
|
|
git commit -m "feat: app owns match/rules/HR and exposes accessors"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: The View — draw the whole screen
|
|
|
|
**Files:**
|
|
- Modify: `source/garmin-padelView.mc`
|
|
|
|
- [ ] **Step 1: Rewrite `garmin-padelView.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
import Toybox.Graphics;
|
|
import Toybox.WatchUi;
|
|
import Toybox.System;
|
|
import Toybox.Timer;
|
|
|
|
class garmin_padelView extends WatchUi.View {
|
|
private const ACCENT = 0x00E0A0; // padel green (sets/games)
|
|
private const LABEL_COLOR = 0x808080; // muted grey (S/G/pts labels)
|
|
|
|
private var _court as CourtRenderer;
|
|
private var _timer as Timer.Timer;
|
|
|
|
public function initialize() {
|
|
View.initialize();
|
|
_court = new CourtRenderer();
|
|
_timer = new Timer.Timer();
|
|
}
|
|
|
|
public function onShow() as Void {
|
|
_timer.start(method(:onTick), 1000, true); // refresh clock + HR every second
|
|
}
|
|
|
|
public function onHide() as Void {
|
|
_timer.stop();
|
|
}
|
|
|
|
public function onTick() as Void {
|
|
WatchUi.requestUpdate();
|
|
}
|
|
|
|
public function onUpdate(dc as Graphics.Dc) as Void {
|
|
var m = getApp().getMatch();
|
|
var w = dc.getWidth();
|
|
var h = dc.getHeight();
|
|
var cx = w / 2;
|
|
var cy = h / 2;
|
|
|
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK);
|
|
dc.clear();
|
|
|
|
_court.draw(dc, cx, cy, m.servingQuadrant(), !m.isMatchOver());
|
|
_drawLeftColumn(dc, m);
|
|
_drawRightColumn(dc, m, w);
|
|
_drawHeartRate(dc, cx, 40);
|
|
_drawTime(dc, cx, h - 44);
|
|
|
|
if (m.isMatchOver()) {
|
|
_drawWinner(dc, cx, cy, m);
|
|
}
|
|
}
|
|
|
|
// Left: S and G mini-columns, your team on top, opponents below.
|
|
private function _drawLeftColumn(dc as Graphics.Dc, m as PadelMatch) as Void {
|
|
var sX = 24;
|
|
var gX = 60;
|
|
var labelY = 150;
|
|
var usY = 178;
|
|
var themY = 206;
|
|
|
|
dc.setColor(LABEL_COLOR, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(sX, labelY, Graphics.FONT_XTINY, "S", Graphics.TEXT_JUSTIFY_CENTER);
|
|
dc.drawText(gX, labelY, Graphics.FONT_XTINY, "G", Graphics.TEXT_JUSTIFY_CENTER);
|
|
|
|
dc.setColor(ACCENT, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(sX, usY, Graphics.FONT_TINY, m.sets(US).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
|
dc.drawText(sX, themY, Graphics.FONT_TINY, m.sets(THEM).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
|
dc.drawText(gX, usY, Graphics.FONT_TINY, m.games(US).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
|
dc.drawText(gX, themY, Graphics.FONT_TINY, m.games(THEM).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
|
}
|
|
|
|
// Right: pts column, your team on top, opponents below.
|
|
private function _drawRightColumn(dc as Graphics.Dc, m as PadelMatch, w as Number) as Void {
|
|
var pX = w - 42;
|
|
var labelY = 150;
|
|
var usY = 178;
|
|
var themY = 206;
|
|
|
|
dc.setColor(LABEL_COLOR, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(pX, labelY, Graphics.FONT_XTINY, "pts", Graphics.TEXT_JUSTIFY_CENTER);
|
|
|
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(pX, usY, Graphics.FONT_TINY, m.pointLabel(US), Graphics.TEXT_JUSTIFY_CENTER);
|
|
dc.drawText(pX, themY, Graphics.FONT_TINY, m.pointLabel(THEM), Graphics.TEXT_JUSTIFY_CENTER);
|
|
}
|
|
|
|
private function _drawHeartRate(dc as Graphics.Dc, cx as Number, y as Number) as Void {
|
|
var hr = getApp().getHeartRate();
|
|
var bpm = hr.getHeartRate();
|
|
var text = (bpm == null) ? "-- bpm" : bpm.toString() + " bpm";
|
|
dc.setColor(Graphics.COLOR_RED, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(cx, y, Graphics.FONT_TINY, text, Graphics.TEXT_JUSTIFY_CENTER);
|
|
_drawZoneBar(dc, cx, y + 26, hr.getZone());
|
|
}
|
|
|
|
// 5-segment bar; segments up to the current zone are filled.
|
|
private function _drawZoneBar(dc as Graphics.Dc, cx as Number, y as Number, zone as Number) as Void {
|
|
var segW = 12;
|
|
var segH = 6;
|
|
var gap = 3;
|
|
var totalW = 5 * segW + 4 * gap;
|
|
var x = cx - totalW / 2;
|
|
for (var i = 1; i <= 5; i += 1) {
|
|
if (i <= zone) {
|
|
dc.setColor(ACCENT, Graphics.COLOR_TRANSPARENT);
|
|
dc.fillRectangle(x, y, segW, segH);
|
|
} else {
|
|
dc.setColor(LABEL_COLOR, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawRectangle(x, y, segW, segH);
|
|
}
|
|
x += segW + gap;
|
|
}
|
|
}
|
|
|
|
private function _drawTime(dc as Graphics.Dc, cx as Number, y as Number) as Void {
|
|
var now = System.getClockTime();
|
|
var text = now.hour.format("%02d") + ":" + now.min.format("%02d");
|
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(cx, y, Graphics.FONT_SMALL, text, Graphics.TEXT_JUSTIFY_CENTER);
|
|
}
|
|
|
|
private function _drawWinner(dc as Graphics.Dc, cx as Number, cy as Number, m as PadelMatch) as Void {
|
|
var text = (m.winner() == US) ? "You win!" : "They win";
|
|
dc.setColor(ACCENT, Graphics.COLOR_BLACK);
|
|
dc.fillRectangle(cx - 70, cy - 18, 140, 36);
|
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(cx, cy - 14, Graphics.FONT_SMALL, text, Graphics.TEXT_JUSTIFY_CENTER);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add source/garmin-padelView.mc
|
|
git commit -m "feat: draw full padel screen (score, court, HR, time)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: The Delegate — gestures
|
|
|
|
**Files:**
|
|
- Modify: `source/garmin-padelDelegate.mc`
|
|
- Delete: `source/garmin-padelMenuDelegate.mc`
|
|
|
|
- [ ] **Step 1: Rewrite `garmin-padelDelegate.mc`**
|
|
|
|
```monkeyc
|
|
import Toybox.Lang;
|
|
import Toybox.WatchUi;
|
|
|
|
class garmin_padelDelegate extends WatchUi.BehaviorDelegate {
|
|
|
|
public function initialize() {
|
|
BehaviorDelegate.initialize();
|
|
}
|
|
|
|
public function onSwipe(swipeEvent as WatchUi.SwipeEvent) as Boolean {
|
|
var m = getApp().getMatch();
|
|
var dir = swipeEvent.getDirection();
|
|
if (dir == WatchUi.SWIPE_UP) {
|
|
m.pointTo(US);
|
|
} else if (dir == WatchUi.SWIPE_DOWN) {
|
|
m.pointTo(THEM);
|
|
} else if (dir == WatchUi.SWIPE_LEFT) {
|
|
m.undo();
|
|
} else {
|
|
return false;
|
|
}
|
|
WatchUi.requestUpdate();
|
|
return true;
|
|
}
|
|
|
|
public function onTap(clickEvent as WatchUi.ClickEvent) as Boolean {
|
|
var m = getApp().getMatch();
|
|
if (m.isMatchOver()) {
|
|
getApp().resetMatch();
|
|
} else if (m.canSetServer()) {
|
|
m.toggleStartingServer();
|
|
} else {
|
|
return false;
|
|
}
|
|
WatchUi.requestUpdate();
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Delete the unused menu delegate**
|
|
|
|
```bash
|
|
git rm source/garmin-padelMenuDelegate.mc
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add source/garmin-padelDelegate.mc
|
|
git commit -m "feat: swipe to score/undo, tap to set server / new match"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Permissions, strings, full build & simulator verification
|
|
|
|
**Files:**
|
|
- Modify: `manifest.xml` (via palette command — do not hand-edit)
|
|
- Modify: `resources/strings/strings.xml`
|
|
|
|
- [ ] **Step 1: Add Sensor + UserProfile permissions**
|
|
|
|
In VS Code run `Monkey C: Edit Permissions` and enable **Sensor** and **UserProfile**. This regenerates `manifest.xml`.
|
|
|
|
Verify from the CLI:
|
|
|
|
```bash
|
|
grep -E "Sensor|UserProfile" manifest.xml
|
|
```
|
|
|
|
Expected: two `<iq:uses-permission id="Sensor"/>` / `<iq:uses-permission id="UserProfile"/>` lines.
|
|
|
|
- [ ] **Step 2: Update strings (remove scaffold menu strings, keep app name)**
|
|
|
|
```xml
|
|
<strings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
|
|
<string id="AppName">Padel</string>
|
|
</strings>
|
|
```
|
|
|
|
(The old `prompt`/`menu_label_*` strings are removed; nothing references them now that the menu is gone.)
|
|
|
|
- [ ] **Step 3: Build the release binary**
|
|
|
|
```bash
|
|
"$SDK/bin/monkeyc" -f monkey.jungle -o bin/garmin-padel.prg -y "$KEY" -d "$DEVICE"
|
|
```
|
|
|
|
Expected: `BUILD SUCCESSFUL`.
|
|
|
|
- [ ] **Step 4: Run in the simulator and verify behavior**
|
|
|
|
```bash
|
|
"$SDK/bin/connectiq" &
|
|
"$SDK/bin/monkeydo" bin/garmin-padel.prg "$DEVICE"
|
|
```
|
|
|
|
Then in the simulator, verify:
|
|
- Court is centered with a yellow ball at **bottom-right** (US serves, deuce).
|
|
- **Tap** → ball jumps to **upper-left**; tap again → back to bottom-right.
|
|
- **Swipe up** → your `pts` go `0→15→30→40`; after the 4th point (golden) your `G` increments and points reset; the ball moves to the opponents' half.
|
|
- **Swipe down** scores for opponents; **swipe left** undoes the last point.
|
|
- Top shows `-- bpm` (or a value if HR data simulation is enabled via the simulator's **Settings → data-simulation / sensors**) with the zone bar; bottom shows the current time.
|
|
- Win a full match → the winner banner appears; **tap** starts a fresh match at server-positioning.
|
|
|
|
- [ ] **Step 5: Re-run unit tests to confirm no regressions**
|
|
|
|
```bash
|
|
"$SDK/bin/monkeyc" -f monkey.jungle -o bin/test.prg -y "$KEY" -d "$DEVICE" --unit-test
|
|
"$SDK/bin/monkeydo" bin/test.prg "$DEVICE" -t
|
|
```
|
|
|
|
Expected: all cases `PASSED`, 0 failures.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add manifest.xml resources/strings/strings.xml
|
|
git commit -m "feat: add Sensor/UserProfile permissions, tidy strings"
|
|
```
|
|
|
|
---
|
|
|
|
## Done
|
|
|
|
The scaffold is now a working padel score tracker. Deferred to future iterations (see spec §8): the settings screen (driving `RulesConfig` and the top/bottom info slots), match history, resume-after-close persistence, and recorded Garmin activity.
|