Files
garmin-padel/docs/superpowers/plans/2026-07-02-padel-scoring.md
2026-07-04 09:58:02 +02:00

33 KiB

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.mcdeleted (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)

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
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

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
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

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
"$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
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

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)
"$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
"$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
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

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
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

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
"$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
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

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
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

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
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

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
git rm source/garmin-padelMenuDelegate.mc
  • Step 3: Commit
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:

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)
<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
"$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
"$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

"$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
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.